Compare commits
2 Commits
a0051576c6
...
2025.07.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e8a601fb6 | |||
| 1bd2846617 |
@@ -11,6 +11,10 @@ PORT=3000
|
||||
# NAVIDROME_USERNAME=your_username
|
||||
# NAVIDROME_PASSWORD=your_password
|
||||
|
||||
# PostHog Analytics (optional)
|
||||
POSTHOG_KEY=
|
||||
POSTHOG_HOST=
|
||||
|
||||
# Example for external Navidrome server:
|
||||
# NAVIDROME_URL=https://your-navidrome-server.com
|
||||
# NAVIDROME_USERNAME=your_username
|
||||
|
||||
@@ -3,9 +3,15 @@ NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
|
||||
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
|
||||
|
||||
# PostHog Analytics (optional)
|
||||
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# For Docker deployment, use these variable names in your .env file:
|
||||
# NAVIDROME_URL=https://your-navidrome-server.com
|
||||
# NAVIDROME_USERNAME=your_username
|
||||
# NAVIDROME_PASSWORD=your_password
|
||||
# POSTHOG_KEY=your_posthog_key
|
||||
# POSTHOG_HOST=https://us.i.posthog.com
|
||||
# HOST_PORT=3000
|
||||
# PORT=3000
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=9427a2a
|
||||
NEXT_PUBLIC_COMMIT_SHA=35febc5
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -81,6 +81,12 @@ jobs:
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=deps-only
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
# - name: Docker Hub Description
|
||||
# uses: peter-evans/dockerhub-description@v4
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -70,11 +70,6 @@ next-env.d.ts
|
||||
# database
|
||||
still-database/
|
||||
|
||||
# Debug related files
|
||||
scripts/sleep-debug.js
|
||||
.vscode/launch.json
|
||||
source-map-support/
|
||||
|
||||
.next/
|
||||
certificates
|
||||
.vercel
|
||||
|
||||
85
.vscode/launch.json
vendored
85
.vscode/launch.json
vendored
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"args": ["dev"],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"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",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"args": ["start"],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"preLaunchTask": "Build: Production Build Only",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "start"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "http://localhost:40625"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
114
.vscode/tasks.json
vendored
114
.vscode/tasks.json
vendored
@@ -1,114 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Dev: Start Development Server",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": [
|
||||
"$tsc-watch"
|
||||
],
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Prod: Build and Start Production",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"-c",
|
||||
"pnpm run build && pnpm run start"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc"],
|
||||
"dependsOrder": "sequence"
|
||||
},
|
||||
{
|
||||
"label": "Debug: Development with Debug Info",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "*",
|
||||
"NEXT_TELEMETRY_DISABLED": "1"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc-watch"]
|
||||
},
|
||||
{
|
||||
"label": "Build: Production Build Only",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"build"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,6 +65,8 @@ When running with Docker, use these variable names (without the `NEXT_PUBLIC_` p
|
||||
- `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set)
|
||||
- `PORT`: Port for the application to listen on (default: `3000`)
|
||||
- `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`)
|
||||
- `POSTHOG_KEY`: PostHog analytics key (optional)
|
||||
- `POSTHOG_HOST`: PostHog analytics host (optional)
|
||||
|
||||
### Development Environment Variables
|
||||
|
||||
@@ -73,6 +75,8 @@ For local development (non-Docker), use these variable names:
|
||||
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server
|
||||
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username
|
||||
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password
|
||||
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
|
||||
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
|
||||
|
||||
**Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables.
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ COPY README.md /app/
|
||||
ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL
|
||||
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME
|
||||
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=NEXT_PUBLIC_POSTHOG_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST=NEXT_PUBLIC_POSTHOG_HOST
|
||||
ENV PORT=3000
|
||||
|
||||
# Generate git commit hash for build info (fallback if not available)
|
||||
|
||||
68
GITHUB_ACTIONS.md
Normal file
68
GITHUB_ACTIONS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# GitHub Actions Docker Publishing Setup
|
||||
|
||||
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to Docker Hub.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
The workflow (`/.github/workflows/publish-docker.yml`) automatically:
|
||||
|
||||
1. **Builds** the Docker image using multi-platform support (AMD64 and ARM64)
|
||||
2. **Publishes** to `sillyangel/mice`
|
||||
3. **Tags** images appropriately based on git refs
|
||||
4. **Caches** layers for faster subsequent builds
|
||||
5. **Generates** build provenance attestations for security
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
The workflow runs on:
|
||||
|
||||
- **Push to main/master branch** → Creates `latest` tag
|
||||
- **Push tags** (e.g., `2025.07.02`) → Creates date-based version tags
|
||||
- **Pull requests** → Creates PR-specific tags for testing
|
||||
- **Manual dispatch** → Can be triggered manually from GitHub UI
|
||||
|
||||
## Image Tags Generated
|
||||
|
||||
Based on different triggers, the workflow creates these tags:
|
||||
|
||||
### Main Branch Push
|
||||
|
||||
- `sillyangel/mice:latest`
|
||||
|
||||
### Tag Push (e.g., `2025.07.02`)
|
||||
|
||||
- `sillyangel/mice:2025.07.02`
|
||||
- `sillyangel/mice:latest`
|
||||
|
||||
### Pull Request
|
||||
|
||||
- `sillyangel/mice:pr-123`
|
||||
|
||||
## Multi-Platform Support
|
||||
|
||||
The workflow builds for multiple architectures:
|
||||
|
||||
- `linux/amd64` (Intel/AMD 64-bit)
|
||||
- `linux/arm64` (ARM 64-bit, Apple Silicon, etc.)
|
||||
|
||||
## Usage After Setup
|
||||
|
||||
Once the workflow is set up:
|
||||
|
||||
1. **Push to main** → New `latest` image published
|
||||
2. **Create a release** → Versioned images published
|
||||
3. **Users can pull**: `docker pull sillyangel/mice:latest`
|
||||
|
||||
## Manual Image Building
|
||||
|
||||
You can also build and push manually:
|
||||
|
||||
```bash
|
||||
# Build for multiple platforms
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t sillyangel/mice:latest \
|
||||
--push .
|
||||
|
||||
# Login first (if needed)
|
||||
echo $DOCKERHUB_TOKEN | docker login -u USERNAME --password-stdin
|
||||
```
|
||||
@@ -1,121 +0,0 @@
|
||||
# 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
|
||||
@@ -1,168 +0,0 @@
|
||||
# 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
|
||||
16
README.md
16
README.md
@@ -1,13 +1,15 @@
|
||||
<p align="center">
|
||||
<img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="120" />
|
||||
<p align="left" style="display: flex; align-items: center; gap: 12px;">
|
||||
<img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="64" style="border-radius: 12px;" />
|
||||
<strong style="font-size: 2rem;">Mice | Navidrome Client</strong>
|
||||
</p>
|
||||
<h1 align="center"><strong>Mice | Navidrome Client</strong></h1>
|
||||
|
||||
#
|
||||
|
||||
> Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template.
|
||||
|
||||
<!-- this looks like "ai" lol but its not -->
|
||||
<!-- This is a music streaming web application built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/), now powered by **Navidrome** for a complete self-hosted music streaming experience. -->
|
||||
|
||||
This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). It creates a beautiful, responsive music streaming web application that connects to your Navidrome server, and fully able to self-host using docker!
|
||||
This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). It creates a beautiful, responsive music streaming web application that connects to your Navidrome server, and fully able to self-host.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -18,7 +20,7 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n
|
||||
- **Search** - Find music across your entire library
|
||||
- **Audio Player** with queue management
|
||||
- **Scrobbling** - Track your listening history
|
||||
- **Playlist Management** - Create and manage playlists
|
||||
<!-- - **Playlist Management** - Create and manage playlists -->
|
||||
|
||||
### Preview
|
||||

|
||||
@@ -115,7 +117,7 @@ docker run -p 3000:3000 \
|
||||
sillyangel/mice:latest
|
||||
```
|
||||
|
||||
**For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
|
||||
📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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!
|
||||
@@ -10,12 +10,9 @@ import Link from 'next/link';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||
import Loading from "@/app/components/loading";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
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();
|
||||
@@ -27,10 +24,7 @@ export default function AlbumPage() {
|
||||
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
||||
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
|
||||
const isMobile = useIsMobile();
|
||||
const api = getNavidromeAPI();
|
||||
const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
@@ -125,233 +119,110 @@ export default function AlbumPage() {
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = async () => {
|
||||
if (!album || !tracklist.length) return;
|
||||
|
||||
try {
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: `Starting download of "${album.name}" by ${album.artist}`,
|
||||
});
|
||||
|
||||
await downloadAlbum(album, tracklist);
|
||||
|
||||
toast({
|
||||
title: "Download Complete",
|
||||
description: `"${album.name}" has been downloaded for offline listening`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download album:', error);
|
||||
toast({
|
||||
title: "Download Failed",
|
||||
description: `Failed to download "${album.name}". Please try again.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic cover art URLs based on image size
|
||||
const getMobileCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 280)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
const getDesktopCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
// Get cover art URL with proper fallback
|
||||
const coverArtUrl = album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="space-y-4">
|
||||
{isMobile ? (
|
||||
/* Mobile Layout */
|
||||
<div className="space-y-6">
|
||||
{/* Album Cover - Centered */}
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src={getMobileCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={280}
|
||||
height={280}
|
||||
className="rounded-md shadow-lg"
|
||||
/>
|
||||
<div className="flex items-start gap-6">
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
alt={album.name}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
|
||||
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
|
||||
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
|
||||
</Button>
|
||||
</div>
|
||||
<Link href={`/artist/${album.artistId}`}>
|
||||
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
|
||||
</Link>
|
||||
<Button className="px-5" onClick={() => playAlbum(album.id)}>
|
||||
Play
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{album.genre} • {album.year}</p>
|
||||
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
|
||||
{/* Album Info and Controls */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{/* Left side - Album Info */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<h1 className="text-2xl font-bold text-left">{album.name}</h1>
|
||||
<Link href={`/artist/${album.artistId}`}>
|
||||
<p className="text-lg text-primary underline text-left">{album.artist}</p>
|
||||
</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 */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
className="w-12 h-12 rounded-full p-0"
|
||||
onClick={() => playAlbum(album.id)}
|
||||
title="Play Album"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
/* Desktop Layout */
|
||||
<div className="flex items-start gap-6">
|
||||
<Image
|
||||
src={getDesktopCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
|
||||
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
|
||||
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
|
||||
{tracklist.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No tracks available.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 pb-32">
|
||||
{tracklist.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
<>
|
||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</>
|
||||
</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 ${
|
||||
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
||||
}`}>
|
||||
{song.title}
|
||||
</p>
|
||||
{/* Song offline indicator */}
|
||||
<OfflineIndicator
|
||||
id={song.id}
|
||||
type="song"
|
||||
size="sm"
|
||||
/>
|
||||
<ScrollArea className="h-[calc(100vh-500px)]">
|
||||
{tracklist.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No tracks available.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{tracklist.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
<>
|
||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate">{song.artist}</span>
|
||||
|
||||
{/* 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 ${
|
||||
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
||||
}`}>
|
||||
{song.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate">{song.artist}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center text-sm text-muted-foreground mr-4">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
{/* Duration */}
|
||||
<div className="flex items-center text-sm text-muted-foreground mr-4">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSongStar(song);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
|
||||
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSongStar(song);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
|
||||
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import Loading from '@/app/components/loading';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export default function ArtistPage() {
|
||||
const { artist: artistId } = useParams();
|
||||
@@ -28,7 +27,6 @@ export default function ArtistPage() {
|
||||
const { getArtist, starItem, unstarItem } = useNavidrome();
|
||||
const { playArtist } = useAudioPlayer();
|
||||
const { toast } = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,7 +103,7 @@ export default function ArtistPage() {
|
||||
}
|
||||
// Get artist image URL with proper fallback
|
||||
const artistImageUrl = artist.coverArt && api
|
||||
? api.getCoverArtUrl(artist.coverArt, 1200)
|
||||
? api.getCoverArtUrl(artist.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
@@ -154,7 +152,7 @@ export default function ArtistPage() {
|
||||
<ArtistBio artistName={artist.name} />
|
||||
|
||||
{/* Popular Songs Section */}
|
||||
{!isMobile && popularSongs.length > 0 && (
|
||||
{popularSongs.length > 0 && (
|
||||
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,53 +1,90 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useState, 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 { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading';
|
||||
import {
|
||||
Shuffle,
|
||||
ArrowDown,
|
||||
RefreshCcw,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Shuffle } 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();
|
||||
|
||||
// Use our progressive loading hook
|
||||
const {
|
||||
albums,
|
||||
isLoading,
|
||||
hasMore,
|
||||
loadMoreAlbums,
|
||||
refreshAlbums
|
||||
} = useProgressiveAlbumLoading('alphabeticalByName');
|
||||
|
||||
// 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(() => {
|
||||
if (inView && hasMore && !isLoading) {
|
||||
loadMoreAlbums();
|
||||
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;
|
||||
}
|
||||
}, [inView, hasMore, isLoading, loadMoreAlbums]);
|
||||
|
||||
// Pull-to-refresh simulation
|
||||
const handleRefresh = useCallback(() => {
|
||||
refreshAlbums();
|
||||
}, [refreshAlbums]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
if (contextLoading) {
|
||||
return <Loading />;
|
||||
@@ -100,10 +137,6 @@ 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">
|
||||
@@ -121,47 +154,24 @@ export default function BrowsePage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Load more sentinel */}
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex justify-center p-4 pb-24"
|
||||
>
|
||||
{hasMoreAlbums && (
|
||||
<div className="flex justify-center p-4 pb-24">
|
||||
<Button
|
||||
onClick={loadMoreAlbums}
|
||||
disabled={isLoading}
|
||||
variant="ghost"
|
||||
className="flex flex-col items-center gap-2"
|
||||
onClick={loadMore}
|
||||
disabled={isLoadingAlbums}
|
||||
variant="outline"
|
||||
>
|
||||
{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>
|
||||
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && albums.length > 0 && (
|
||||
{!hasMoreAlbums && 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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Song, Album, Artist } 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;
|
||||
@@ -16,16 +15,8 @@ export interface Track {
|
||||
coverArt?: string;
|
||||
albumId: string;
|
||||
artistId: string;
|
||||
autoPlay?: boolean;
|
||||
starred?: boolean;
|
||||
replayGain?: number; // Added ReplayGain field
|
||||
}
|
||||
|
||||
interface AudioSettings {
|
||||
crossfadeDuration: number;
|
||||
equalizer: string;
|
||||
replayGainEnabled: boolean;
|
||||
gaplessPlayback: boolean;
|
||||
autoPlay?: boolean; // Flag to control auto-play
|
||||
starred?: boolean; // Flag for starred/favorited tracks
|
||||
}
|
||||
|
||||
interface AudioPlayerContextProps {
|
||||
@@ -33,14 +24,12 @@ 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;
|
||||
@@ -53,47 +42,18 @@ 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();
|
||||
if (!navidromeApi) {
|
||||
console.warn('⚠️ Navidrome API not configured');
|
||||
} else {
|
||||
console.log('✅ Navidrome API initialized');
|
||||
}
|
||||
return navidromeApi;
|
||||
}, []);
|
||||
const api = useMemo(() => getNavidromeAPI(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedQueue = localStorage.getItem('navidrome-audioQueue');
|
||||
@@ -134,93 +94,21 @@ 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');
|
||||
}
|
||||
|
||||
const streamUrl = api.getStreamUrl(song.id);
|
||||
console.log('🎵 Creating track with stream URL:', streamUrl);
|
||||
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: streamUrl,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred,
|
||||
replayGain: song.replayGain || 0 // Add ReplayGain support
|
||||
starred: !!song.starred
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -230,40 +118,6 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
|
||||
if (currentTrack) {
|
||||
setPlayedTracks((prev) => [...prev, currentTrack]);
|
||||
|
||||
// Record the play for listening streak
|
||||
// This will store timestamp with the track play
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const streakData = localStorage.getItem('navidrome-streak-data');
|
||||
|
||||
if (streakData) {
|
||||
const parsedData = JSON.parse(streakData);
|
||||
const todayData = parsedData[today] || {
|
||||
date: today,
|
||||
tracks: 0,
|
||||
uniqueArtists: [],
|
||||
uniqueAlbums: [],
|
||||
totalListeningTime: 0
|
||||
};
|
||||
|
||||
// Update today's listening data
|
||||
todayData.tracks += 1;
|
||||
if (!todayData.uniqueArtists.includes(currentTrack.artistId)) {
|
||||
todayData.uniqueArtists.push(currentTrack.artistId);
|
||||
}
|
||||
if (!todayData.uniqueAlbums.includes(currentTrack.albumId)) {
|
||||
todayData.uniqueAlbums.push(currentTrack.albumId);
|
||||
}
|
||||
todayData.totalListeningTime += currentTrack.duration;
|
||||
|
||||
// Save updated data
|
||||
parsedData[today] = todayData;
|
||||
localStorage.setItem('navidrome-streak-data', JSON.stringify(parsedData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update listening streak data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set autoPlay flag on the track
|
||||
@@ -293,10 +147,6 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
});
|
||||
}, [shuffle]);
|
||||
|
||||
const insertAtBeginningOfQueue = useCallback((track: Track) => {
|
||||
setQueue((prevQueue) => [track, ...prevQueue]);
|
||||
}, []);
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
setQueue([]);
|
||||
}, []);
|
||||
@@ -305,15 +155,6 @@ 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');
|
||||
@@ -720,43 +561,15 @@ 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,
|
||||
@@ -769,15 +582,6 @@ 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({
|
||||
@@ -852,12 +656,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
isLoading,
|
||||
playTrack,
|
||||
addToQueue,
|
||||
insertAtBeginningOfQueue,
|
||||
playNextTrack,
|
||||
clearQueue,
|
||||
addAlbumToQueue,
|
||||
removeTrackFromQueue,
|
||||
reorderQueue,
|
||||
addArtistToQueue,
|
||||
playPreviousTrack,
|
||||
playAlbum,
|
||||
@@ -870,14 +672,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
playedTracks,
|
||||
clearHistory,
|
||||
api,
|
||||
toast,
|
||||
audioEffects,
|
||||
audioSettings,
|
||||
equalizerPreset,
|
||||
updateAudioSettings,
|
||||
setEqualizerPreset,
|
||||
isPlaying,
|
||||
togglePlayPause
|
||||
toast
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
'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;
|
||||
@@ -1,319 +0,0 @@
|
||||
'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;
|
||||
@@ -1,221 +0,0 @@
|
||||
'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't set too fast</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AutoTaggingDialog
|
||||
isOpen={autoTagDialogOpen}
|
||||
onClose={() => setAutoTagDialogOpen(false)}
|
||||
mode={selectedItem.mode}
|
||||
itemId={selectedItem.id}
|
||||
itemName={selectedItem.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navigationItems: NavItem[] = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/search', label: 'Search', icon: Search },
|
||||
{ href: '/library', label: 'Library', icon: Music },
|
||||
{ href: '/queue', label: 'Queue', icon: List },
|
||||
];
|
||||
|
||||
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) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-[50] bg-background/95 backdrop-blur-sm border-t border-border">
|
||||
<div className="flex items-center justify-around px-2 py-2 pb-safe mb-2">
|
||||
{navigationItems.map((item) => {
|
||||
const isItemActive = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={item.href}
|
||||
onClick={() => handleNavigation(item.href)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2 rounded-lg transition-all duration-200 min-w-[60px] touch-manipulation",
|
||||
"active:scale-95 active:bg-primary/20",
|
||||
isItemActive
|
||||
? "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(
|
||||
"text-xs font-medium",
|
||||
isItemActive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
app/components/CacheManagement.tsx
Normal file
223
app/components/CacheManagement.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } 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 {
|
||||
Database,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
HardDrive
|
||||
} from 'lucide-react';
|
||||
import { CacheManager } from '@/lib/cache';
|
||||
|
||||
export function CacheManagement() {
|
||||
const [cacheStats, setCacheStats] = useState({
|
||||
total: 0,
|
||||
expired: 0,
|
||||
size: '0 B'
|
||||
});
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [lastCleared, setLastCleared] = useState<string | null>(null);
|
||||
|
||||
const loadCacheStats = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let total = 0;
|
||||
let expired = 0;
|
||||
let totalSize = 0;
|
||||
const now = Date.now();
|
||||
|
||||
// Check localStorage for cache entries
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
|
||||
total++;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) {
|
||||
totalSize += key.length + value.length;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed.expiresAt && now > parsed.expiresAt) {
|
||||
expired++;
|
||||
}
|
||||
} catch (error) {
|
||||
expired++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert bytes to human readable format
|
||||
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];
|
||||
};
|
||||
|
||||
setCacheStats({
|
||||
total,
|
||||
expired,
|
||||
size: formatSize(totalSize * 2) // *2 for UTF-16 encoding
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCacheStats();
|
||||
|
||||
// Check if there's a last cleared timestamp
|
||||
const lastClearedTime = localStorage.getItem('cache-last-cleared');
|
||||
if (lastClearedTime) {
|
||||
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
// Clear all cache using the CacheManager
|
||||
CacheManager.clearAll();
|
||||
|
||||
// Also clear any other cache-related localStorage items
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith('cache-') ||
|
||||
key.startsWith('navidrome-cache-') ||
|
||||
key.startsWith('library-cache-') ||
|
||||
key.includes('album') ||
|
||||
key.includes('artist') ||
|
||||
key.includes('song')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Set last cleared timestamp
|
||||
localStorage.setItem('cache-last-cleared', Date.now().toString());
|
||||
}
|
||||
|
||||
// Update stats
|
||||
loadCacheStats();
|
||||
setLastCleared(new Date().toLocaleString());
|
||||
|
||||
// Show success feedback
|
||||
setTimeout(() => {
|
||||
setIsClearing(false);
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanExpired = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const now = Date.now();
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed.expiresAt && now > parsed.expiresAt) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid cache item, remove it
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
loadCacheStats();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Cache Management
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage application cache to improve performance and free up storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Cache Statistics */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold">{cacheStats.total}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Items</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold">{cacheStats.expired}</p>
|
||||
<p className="text-xs text-muted-foreground">Expired</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold">{cacheStats.size}</p>
|
||||
<p className="text-xs text-muted-foreground">Storage Used</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Actions */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{isClearing ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isClearing ? 'Clearing...' : 'Clear All Cache'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleCleanExpired}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<HardDrive className="h-4 w-4 mr-2" />
|
||||
Clean Expired
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={loadCacheStats}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh Stats
|
||||
</Button>
|
||||
</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>
|
||||
{lastCleared && (
|
||||
<p>Last cleared: {lastCleared}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListeningStreak } from '@/hooks/use-listening-streak';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
export default function CompactListeningStreak() {
|
||||
const { stats, hasListenedToday, getStreakEmoji } = useListeningStreak();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
|
||||
// Trigger animation when streak increases
|
||||
useEffect(() => {
|
||||
if (stats.currentStreak > 0) {
|
||||
setAnimate(true);
|
||||
const timer = setTimeout(() => setAnimate(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [stats.currentStreak]);
|
||||
|
||||
const hasCompletedToday = hasListenedToday();
|
||||
const streakEmoji = getStreakEmoji();
|
||||
|
||||
// Only show if the streak is 3 days or more
|
||||
if (stats.currentStreak < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className={cn(
|
||||
"w-5 h-5",
|
||||
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
|
||||
)} />
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={stats.currentStreak}
|
||||
initial={{ scale: animate ? 0.8 : 1 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="flex items-center"
|
||||
>
|
||||
<span className="text-xl font-bold">
|
||||
{stats.currentStreak}
|
||||
</span>
|
||||
<span className="ml-1 text-sm text-muted-foreground">
|
||||
day streak
|
||||
</span>
|
||||
{streakEmoji && (
|
||||
<motion.span
|
||||
className="ml-1 text-xl"
|
||||
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
|
||||
>
|
||||
{streakEmoji}
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasCompletedToday ? "Today's goal complete!" : "Keep listening!"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -1,627 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Database,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Music,
|
||||
User,
|
||||
List,
|
||||
HardDrive,
|
||||
Disc,
|
||||
Search,
|
||||
Filter,
|
||||
SlidersHorizontal
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import Image from 'next/image';
|
||||
import { Album, Playlist } from '@/lib/navidrome';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { OfflineManagement } from './OfflineManagement';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// Helper functions
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return 'Never';
|
||||
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Album card for selection
|
||||
function AlbumSelectionCard({
|
||||
album,
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
estimatedSize
|
||||
}: {
|
||||
album: Album;
|
||||
isSelected: boolean;
|
||||
onToggleSelection: () => void;
|
||||
isDownloading: boolean;
|
||||
downloadProgress?: number;
|
||||
estimatedSize: string;
|
||||
}) {
|
||||
const { api } = useNavidrome();
|
||||
|
||||
return (
|
||||
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex p-3">
|
||||
<div className="shrink-0">
|
||||
<Image
|
||||
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
|
||||
alt={album.name}
|
||||
width={60}
|
||||
height={60}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<h4 className="font-medium truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-muted-foreground">{album.songCount} songs • {estimatedSize}</span>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && downloadProgress !== undefined && (
|
||||
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Playlist selection card
|
||||
function PlaylistSelectionCard({
|
||||
playlist,
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
estimatedSize
|
||||
}: {
|
||||
playlist: Playlist;
|
||||
isSelected: boolean;
|
||||
onToggleSelection: () => void;
|
||||
isDownloading: boolean;
|
||||
downloadProgress?: number;
|
||||
estimatedSize: string;
|
||||
}) {
|
||||
const { api } = useNavidrome();
|
||||
|
||||
return (
|
||||
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex p-3">
|
||||
<div className="shrink-0">
|
||||
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
|
||||
<List className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<h4 className="font-medium truncate">{playlist.name}</h4>
|
||||
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-muted-foreground">{playlist.songCount} songs • {estimatedSize}</span>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && downloadProgress !== undefined && (
|
||||
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnhancedOfflineManager() {
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState({
|
||||
albums: false,
|
||||
playlists: false
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
|
||||
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
|
||||
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Filter state
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
const [filtersVisible, setFiltersVisible] = useState(false);
|
||||
|
||||
const offline = useOfflineLibrary();
|
||||
const { api } = useNavidrome();
|
||||
|
||||
// Load albums and playlists
|
||||
// ...existing code...
|
||||
|
||||
// ...existing code...
|
||||
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
|
||||
|
||||
// Load albums data
|
||||
const loadAlbums = async () => {
|
||||
setLoading(prev => ({ ...prev, albums: true }));
|
||||
try {
|
||||
const albumData = await offline.getAlbums();
|
||||
setAlbums(albumData);
|
||||
|
||||
// Load previously selected albums from localStorage
|
||||
const savedSelections = localStorage.getItem('navidrome-offline-albums');
|
||||
if (savedSelections) {
|
||||
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load albums:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load albums. Please try again.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, albums: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Load playlists data
|
||||
const loadPlaylists = async () => {
|
||||
setLoading(prev => ({ ...prev, playlists: true }));
|
||||
try {
|
||||
const playlistData = await offline.getPlaylists();
|
||||
setPlaylists(playlistData);
|
||||
|
||||
// Load previously selected playlists from localStorage
|
||||
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
|
||||
if (savedSelections) {
|
||||
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlists:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load playlists. Please try again.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, playlists: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle album selection
|
||||
const toggleAlbumSelection = (albumId: string) => {
|
||||
setSelectedAlbums(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(albumId)) {
|
||||
newSelection.delete(albumId);
|
||||
} else {
|
||||
newSelection.add(albumId);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle playlist selection
|
||||
const togglePlaylistSelection = (playlistId: string) => {
|
||||
setSelectedPlaylists(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(playlistId)) {
|
||||
newSelection.delete(playlistId);
|
||||
} else {
|
||||
newSelection.add(playlistId);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
// Download selected items
|
||||
const downloadSelected = async () => {
|
||||
// Mock implementation - in a real implementation, you'd integrate with the download system
|
||||
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
|
||||
if (selectedIds.length === 0) {
|
||||
toast({
|
||||
title: 'No items selected',
|
||||
description: 'Please select albums or playlists to download.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Download Started',
|
||||
description: `Downloading ${selectedIds.length} items for offline use.`,
|
||||
});
|
||||
|
||||
// Mock download progress
|
||||
const downloadMap = new Map<string, number>();
|
||||
selectedIds.forEach(id => downloadMap.set(id, 0));
|
||||
setDownloadingItems(downloadMap);
|
||||
|
||||
// Simulate download progress
|
||||
const interval = setInterval(() => {
|
||||
setDownloadingItems(prev => {
|
||||
const updated = new Map(prev);
|
||||
let allComplete = true;
|
||||
|
||||
for (const [id, progress] of prev.entries()) {
|
||||
if (progress < 100) {
|
||||
updated.set(id, Math.min(progress + Math.random() * 10, 100));
|
||||
allComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allComplete) {
|
||||
clearInterval(interval);
|
||||
toast({
|
||||
title: 'Download Complete',
|
||||
description: `${selectedIds.length} items are now available offline.`,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setDownloadingItems(new Map());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Filter and sort albums
|
||||
const filteredAlbums = albums
|
||||
.filter(album => {
|
||||
if (!searchQuery) return true;
|
||||
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'artist':
|
||||
return a.artist.localeCompare(b.artist);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort playlists
|
||||
const filteredPlaylists = playlists
|
||||
.filter(playlist => {
|
||||
if (!searchQuery) return true;
|
||||
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate album size (mock implementation)
|
||||
const estimateSize = (songCount: number) => {
|
||||
const averageSongSizeMB = 8;
|
||||
const totalSizeMB = songCount * averageSongSizeMB;
|
||||
if (totalSizeMB > 1000) {
|
||||
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${totalSizeMB.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="space-y-4"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="albums">Albums</TabsTrigger>
|
||||
<TabsTrigger value="playlists">Playlists</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OfflineManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="albums" className="space-y-4">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Disc className="h-5 w-5" />
|
||||
Select Albums
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose albums to make available offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search albums..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtersVisible && (
|
||||
<div className="p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Sort By</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('recent')}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('name')}
|
||||
>
|
||||
Name
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'artist' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('artist')}
|
||||
>
|
||||
Artist
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedAlbums(new Set())}
|
||||
disabled={selectedAlbums.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||
{loading.albums ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<div className="flex p-3">
|
||||
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : filteredAlbums.length > 0 ? (
|
||||
filteredAlbums.map(album => (
|
||||
<AlbumSelectionCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
isSelected={selectedAlbums.has(album.id)}
|
||||
onToggleSelection={() => toggleAlbumSelection(album.id)}
|
||||
isDownloading={downloadingItems.has(album.id)}
|
||||
downloadProgress={downloadingItems.get(album.id)}
|
||||
estimatedSize={estimateSize(album.songCount)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={downloadSelected}
|
||||
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="playlists" className="space-y-4">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<List className="h-5 w-5" />
|
||||
Select Playlists
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose playlists to make available offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search playlists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtersVisible && (
|
||||
<div className="p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Sort By</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('recent')}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('name')}
|
||||
>
|
||||
Name
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPlaylists(new Set())}
|
||||
disabled={selectedPlaylists.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||
{loading.playlists ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<div className="flex p-3">
|
||||
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : filteredPlaylists.length > 0 ? (
|
||||
filteredPlaylists.map(playlist => (
|
||||
<PlaylistSelectionCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
isSelected={selectedPlaylists.has(playlist.id)}
|
||||
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
|
||||
isDownloading={downloadingItems.has(playlist.id)}
|
||||
downloadProgress={downloadingItems.get(playlist.id)}
|
||||
estimatedSize={estimateSize(playlist.songCount)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={downloadSelected}
|
||||
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
'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;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListeningStreak } from '@/hooks/use-listening-streak';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Flame, Calendar, Clock, Music, Disc, User2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function ListeningStreakCard() {
|
||||
const { stats, hasListenedToday, getStreakEmoji, getTodaySummary, streakThresholds } = useListeningStreak();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
|
||||
// Trigger animation when streak increases
|
||||
useEffect(() => {
|
||||
if (stats.currentStreak > 0) {
|
||||
setAnimate(true);
|
||||
const timer = setTimeout(() => setAnimate(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [stats.currentStreak]);
|
||||
|
||||
const todaySummary = getTodaySummary();
|
||||
const hasCompletedToday = hasListenedToday();
|
||||
|
||||
// Calculate progress towards today's goal
|
||||
const trackProgress = Math.min(100, (todaySummary.tracks / streakThresholds.tracks) * 100);
|
||||
const timeInMinutes = parseInt(todaySummary.time.replace('m', ''), 10) || 0;
|
||||
const timeThresholdMinutes = Math.floor(streakThresholds.time / 60);
|
||||
const timeProgress = Math.min(100, (timeInMinutes / timeThresholdMinutes) * 100);
|
||||
|
||||
// Overall progress (highest of the two metrics)
|
||||
const overallProgress = Math.max(trackProgress, timeProgress);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className={cn(
|
||||
"w-5 h-5 transition-all",
|
||||
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
|
||||
)} />
|
||||
<span>Listening Streak</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{stats.totalDaysListened} days
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={stats.currentStreak}
|
||||
initial={{ scale: animate ? 0.5 : 1 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.5 }}
|
||||
className="relative mb-2"
|
||||
>
|
||||
<div className="text-5xl font-bold text-center">
|
||||
{stats.currentStreak}
|
||||
</div>
|
||||
<div className="text-sm text-center text-muted-foreground">
|
||||
day{stats.currentStreak !== 1 ? 's' : ''} streak
|
||||
</div>
|
||||
{getStreakEmoji() && (
|
||||
<motion.div
|
||||
className="absolute -top-2 -right-4 text-2xl"
|
||||
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{getStreakEmoji()}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="w-full mt-4">
|
||||
<div className="flex justify-between items-center text-sm mb-1">
|
||||
<span className="text-muted-foreground">Today's Progress</span>
|
||||
<span className={cn(
|
||||
hasCompletedToday ? "text-green-500 font-medium" : "text-muted-foreground"
|
||||
)}>
|
||||
{hasCompletedToday ? "Complete!" : "In progress..."}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={overallProgress}
|
||||
className={cn(
|
||||
"h-2",
|
||||
hasCompletedToday ? "bg-green-500/20" : "",
|
||||
hasCompletedToday ? "[&>div]:bg-green-500" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 w-full mt-6">
|
||||
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Music className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Tracks</span>
|
||||
</div>
|
||||
<span className="text-xl font-semibold">{todaySummary.tracks}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Goal: {streakThresholds.tracks}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Time</span>
|
||||
</div>
|
||||
<span className="text-xl font-semibold">{todaySummary.time}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Goal: {timeThresholdMinutes}m
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 w-full mt-4">
|
||||
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Artists</span>
|
||||
</div>
|
||||
<span className="text-xl font-semibold">{todaySummary.artists}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Disc className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Albums</span>
|
||||
</div>
|
||||
<span className="text-xl font-semibold">{todaySummary.albums}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-center text-muted-foreground">
|
||||
{hasCompletedToday ? (
|
||||
<span>You've met your daily listening goal! 🎵</span>
|
||||
) : (
|
||||
<span>Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Check, X, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function OfflineIndicator({
|
||||
id,
|
||||
type,
|
||||
className,
|
||||
showLabel = false,
|
||||
size = 'md'
|
||||
}: OfflineIndicatorProps) {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const { checkOfflineStatus, isInitialized } = useOfflineDownloads();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await checkOfflineStatus(id, type);
|
||||
if (mounted) {
|
||||
setIsOffline(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
if (mounted) {
|
||||
setIsOffline(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id, type, isInitialized, checkOfflineStatus]);
|
||||
|
||||
const iconSize = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5'
|
||||
}[size];
|
||||
|
||||
const textSize = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
}[size];
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 text-muted-foreground', className)}>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin')} />
|
||||
{showLabel && <span className={textSize}>Checking...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOffline) {
|
||||
return null; // Don't show anything if not downloaded
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 text-green-600', className)}>
|
||||
<Download className={iconSize} />
|
||||
{showLabel && (
|
||||
<span className={textSize}>
|
||||
{type === 'album' ? 'Album Downloaded' : 'Downloaded'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DownloadButtonProps {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
onDownload?: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DownloadButton({
|
||||
id,
|
||||
type,
|
||||
onDownload,
|
||||
className,
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
children
|
||||
}: DownloadButtonProps) {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const {
|
||||
checkOfflineStatus,
|
||||
deleteOfflineContent,
|
||||
isInitialized,
|
||||
downloadProgress
|
||||
} = useOfflineDownloads();
|
||||
|
||||
const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting';
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await checkOfflineStatus(id, type);
|
||||
if (mounted) {
|
||||
setIsOffline(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
if (mounted) {
|
||||
setIsOffline(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id, type, isInitialized, checkOfflineStatus]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isOffline) {
|
||||
// Remove from offline storage
|
||||
try {
|
||||
await deleteOfflineContent(id, type);
|
||||
setIsOffline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete offline content:', error);
|
||||
}
|
||||
} else {
|
||||
// Start download
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSize = {
|
||||
sm: 'sm',
|
||||
md: 'default',
|
||||
lg: 'lg'
|
||||
}[size] as 'sm' | 'default' | 'lg';
|
||||
|
||||
const iconSize = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5'
|
||||
}[size];
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={buttonSize}
|
||||
disabled
|
||||
className={className}
|
||||
>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
|
||||
{children || 'Checking...'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={buttonSize}
|
||||
onClick={handleClick}
|
||||
disabled={isDownloading}
|
||||
className={className}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
|
||||
{children || 'Downloading...'}
|
||||
</>
|
||||
) : isOffline ? (
|
||||
<>
|
||||
<X className={cn(iconSize, 'mr-2')} />
|
||||
{children || 'Remove Download'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className={cn(iconSize, 'mr-2')} />
|
||||
{children || 'Download'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Database,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Music,
|
||||
User,
|
||||
List,
|
||||
HardDrive
|
||||
} from 'lucide-react';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return 'Never';
|
||||
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
export function OfflineManagement() {
|
||||
const { toast } = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const {
|
||||
isInitialized,
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSync,
|
||||
stats,
|
||||
syncProgress,
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
clearOfflineData,
|
||||
refreshStats
|
||||
} = useOfflineLibrary();
|
||||
|
||||
// Refresh stats periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isInitialized && !isSyncing) {
|
||||
refreshStats();
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isInitialized, isSyncing, refreshStats]);
|
||||
|
||||
const handleFullSync = async () => {
|
||||
try {
|
||||
await syncLibraryFromServer();
|
||||
toast({
|
||||
title: "Sync Complete",
|
||||
description: "Your music library has been synced for offline use.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Full sync failed:', error);
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: "Failed to sync library. Check your connection and try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePendingSync = async () => {
|
||||
try {
|
||||
await syncPendingOperations();
|
||||
toast({
|
||||
title: "Pending Operations Synced",
|
||||
description: "All pending changes have been synced to the server.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Pending sync failed:', error);
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: "Failed to sync pending operations. Will retry automatically when online.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearData = async () => {
|
||||
if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearOfflineData();
|
||||
toast({
|
||||
title: "Offline Data Cleared",
|
||||
description: "All offline music data has been removed.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Clear data failed:', error);
|
||||
toast({
|
||||
title: "Clear Failed",
|
||||
description: "Failed to clear offline data. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Offline Library
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Setting up offline library...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Initializing offline storage...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connection Status */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isOnline ? (
|
||||
<Wifi className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
Connection Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isOnline ? "default" : "destructive"}>
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isOnline ? "Connected to Navidrome server" : "Working offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats.pendingOperations > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-600">
|
||||
{stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync Status */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Library Sync
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Keep your offline library up to date
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isSyncing && syncProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{syncProgress.stage}</span>
|
||||
<span>{syncProgress.current}%</span>
|
||||
</div>
|
||||
<Progress value={syncProgress.current} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Last Sync</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(lastSync)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{stats.pendingOperations > 0 && isOnline && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePendingSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Sync Pending ({stats.pendingOperations})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleFullSync}
|
||||
disabled={!isOnline || isSyncing}
|
||||
size="sm"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
{isSyncing ? 'Syncing...' : 'Full Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Library Statistics */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Offline Library Stats
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your offline music collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Music className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.albums.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.artists.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Artists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Music className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.songs.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Songs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<List className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.playlists.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Playlists</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Storage Used</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBytes(stats.storageSize)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Offline Features */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>Offline Features</CardTitle>
|
||||
<CardDescription>
|
||||
What works when you're offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Browse & Search</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse your synced albums, artists, and search offline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Favorites & Playlists</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Star songs/albums and create playlists (syncs when online)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Play Downloaded Music</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play songs you've downloaded for offline listening
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Auto-Sync</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes sync automatically when you reconnect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="mb-6 break-inside-avoid py-5 border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600 flex items-center gap-2">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete all offline data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Clear All Offline Data</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will remove all synced library data and downloaded audio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearData}
|
||||
disabled={isClearing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{isClearing ? 'Clearing...' : 'Clear Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
|
||||
interface OfflineNavidromeContextType {
|
||||
// Data (offline-first)
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
playlists: Playlist[];
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
albumsLoading: boolean;
|
||||
artistsLoading: boolean;
|
||||
playlistsLoading: boolean;
|
||||
|
||||
// Connection state
|
||||
isOnline: boolean;
|
||||
isOfflineReady: boolean;
|
||||
|
||||
// Error states
|
||||
error: string | null;
|
||||
|
||||
// Offline sync status
|
||||
isSyncing: boolean;
|
||||
lastSync: Date | null;
|
||||
pendingOperations: number;
|
||||
|
||||
// Methods (offline-aware)
|
||||
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>;
|
||||
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>;
|
||||
getPlaylists: () => Promise<Playlist[]>;
|
||||
refreshData: () => Promise<void>;
|
||||
|
||||
// Offline-capable operations
|
||||
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<Playlist>;
|
||||
scrobble: (songId: string) => Promise<void>;
|
||||
|
||||
// Sync management
|
||||
syncLibrary: () => Promise<void>;
|
||||
syncPendingOperations: () => Promise<void>;
|
||||
clearOfflineData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineNavidromeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OfflineNavidromeProvider: React.FC<OfflineNavidromeProviderProps> = ({ children }) => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false);
|
||||
const [artistsLoading, setArtistsLoading] = useState(false);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use the original Navidrome context for online operations
|
||||
const originalNavidrome = useNavidrome();
|
||||
|
||||
// Use offline library for offline operations
|
||||
const {
|
||||
isInitialized: isOfflineReady,
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSync,
|
||||
stats,
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations: syncPendingOps,
|
||||
getAlbums: getAlbumsOffline,
|
||||
getArtists: getArtistsOffline,
|
||||
getAlbum: getAlbumOffline,
|
||||
getPlaylists: getPlaylistsOffline,
|
||||
searchOffline,
|
||||
starOffline,
|
||||
unstarOffline,
|
||||
createPlaylistOffline,
|
||||
scrobbleOffline,
|
||||
clearOfflineData: clearOfflineDataInternal,
|
||||
refreshStats
|
||||
} = useOfflineLibrary();
|
||||
|
||||
const isLoading = albumsLoading || artistsLoading || playlistsLoading;
|
||||
const pendingOperations = stats.pendingOperations;
|
||||
|
||||
// Load initial data (offline-first approach)
|
||||
const loadAlbums = useCallback(async () => {
|
||||
setAlbumsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const albumData = await getAlbumsOffline();
|
||||
setAlbums(albumData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load albums:', err);
|
||||
setError('Failed to load albums');
|
||||
} finally {
|
||||
setAlbumsLoading(false);
|
||||
}
|
||||
}, [getAlbumsOffline]);
|
||||
|
||||
const loadArtists = useCallback(async () => {
|
||||
setArtistsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const artistData = await getArtistsOffline();
|
||||
setArtists(artistData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load artists:', err);
|
||||
setError('Failed to load artists');
|
||||
} finally {
|
||||
setArtistsLoading(false);
|
||||
}
|
||||
}, [getArtistsOffline]);
|
||||
|
||||
const loadPlaylists = useCallback(async () => {
|
||||
setPlaylistsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const playlistData = await getPlaylistsOffline();
|
||||
setPlaylists(playlistData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlists:', err);
|
||||
setError('Failed to load playlists');
|
||||
} finally {
|
||||
setPlaylistsLoading(false);
|
||||
}
|
||||
}, [getPlaylistsOffline]);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]);
|
||||
await refreshStats();
|
||||
}, [loadAlbums, loadArtists, loadPlaylists, refreshStats]);
|
||||
|
||||
// Initialize data when offline library is ready
|
||||
useEffect(() => {
|
||||
if (isOfflineReady) {
|
||||
refreshData();
|
||||
}
|
||||
}, [isOfflineReady, refreshData]);
|
||||
|
||||
// Auto-sync when coming back online
|
||||
useEffect(() => {
|
||||
if (isOnline && isOfflineReady && pendingOperations > 0) {
|
||||
console.log('Back online with pending operations, starting sync...');
|
||||
syncPendingOps();
|
||||
}
|
||||
}, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]);
|
||||
|
||||
// Offline-first methods
|
||||
const searchMusic = useCallback(async (query: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await searchOffline(query);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
setError('Search failed');
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
}, [searchOffline]);
|
||||
|
||||
const getAlbum = useCallback(async (albumId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await getAlbumOffline(albumId);
|
||||
} catch (err) {
|
||||
console.error('Failed to get album:', err);
|
||||
setError('Failed to get album');
|
||||
return null;
|
||||
}
|
||||
}, [getAlbumOffline]);
|
||||
|
||||
const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => {
|
||||
setError(null);
|
||||
try {
|
||||
// For now, use the original implementation if online, or search offline
|
||||
if (isOnline && originalNavidrome.api) {
|
||||
return await originalNavidrome.getArtist(artistId);
|
||||
} else {
|
||||
// Try to find artist in offline data
|
||||
const allArtists = await getArtistsOffline();
|
||||
const artist = allArtists.find(a => a.id === artistId);
|
||||
if (!artist) return null;
|
||||
|
||||
const allAlbums = await getAlbumsOffline();
|
||||
const artistAlbums = allAlbums.filter(a => a.artistId === artistId);
|
||||
|
||||
return { artist, albums: artistAlbums };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get artist:', err);
|
||||
setError('Failed to get artist');
|
||||
return null;
|
||||
}
|
||||
}, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]);
|
||||
|
||||
const getPlaylistsWrapper = useCallback(async (): Promise<Playlist[]> => {
|
||||
try {
|
||||
return await getPlaylistsOffline();
|
||||
} catch (err) {
|
||||
console.error('Failed to get playlists:', err);
|
||||
return [];
|
||||
}
|
||||
}, [getPlaylistsOffline]);
|
||||
|
||||
// Offline-capable operations
|
||||
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
setError(null);
|
||||
try {
|
||||
await starOffline(id, type);
|
||||
// Refresh relevant data
|
||||
if (type === 'album') {
|
||||
await loadAlbums();
|
||||
} else if (type === 'artist') {
|
||||
await loadArtists();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to star item:', err);
|
||||
setError('Failed to star item');
|
||||
throw err;
|
||||
}
|
||||
}, [starOffline, loadAlbums, loadArtists]);
|
||||
|
||||
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
setError(null);
|
||||
try {
|
||||
await unstarOffline(id, type);
|
||||
// Refresh relevant data
|
||||
if (type === 'album') {
|
||||
await loadAlbums();
|
||||
} else if (type === 'artist') {
|
||||
await loadArtists();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to unstar item:', err);
|
||||
setError('Failed to unstar item');
|
||||
throw err;
|
||||
}
|
||||
}, [unstarOffline, loadAlbums, loadArtists]);
|
||||
|
||||
const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
|
||||
setError(null);
|
||||
try {
|
||||
const playlist = await createPlaylistOffline(name, songIds);
|
||||
await loadPlaylists(); // Refresh playlists
|
||||
return playlist;
|
||||
} catch (err) {
|
||||
console.error('Failed to create playlist:', err);
|
||||
setError('Failed to create playlist');
|
||||
throw err;
|
||||
}
|
||||
}, [createPlaylistOffline, loadPlaylists]);
|
||||
|
||||
const scrobble = useCallback(async (songId: string) => {
|
||||
try {
|
||||
await scrobbleOffline(songId);
|
||||
} catch (err) {
|
||||
console.error('Failed to scrobble:', err);
|
||||
// Don't set error state for scrobbling failures as they're not critical
|
||||
}
|
||||
}, [scrobbleOffline]);
|
||||
|
||||
// Sync management
|
||||
const syncLibrary = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await syncLibraryFromServer();
|
||||
await refreshData(); // Refresh local state after sync
|
||||
} catch (err) {
|
||||
console.error('Library sync failed:', err);
|
||||
setError('Library sync failed');
|
||||
throw err;
|
||||
}
|
||||
}, [syncLibraryFromServer, refreshData]);
|
||||
|
||||
const syncPendingOperations = useCallback(async () => {
|
||||
try {
|
||||
await syncPendingOps();
|
||||
await refreshStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to sync pending operations:', err);
|
||||
// Don't throw or set error for pending operations sync
|
||||
}
|
||||
}, [syncPendingOps, refreshStats]);
|
||||
|
||||
const clearOfflineData = useCallback(async () => {
|
||||
try {
|
||||
await clearOfflineDataInternal();
|
||||
setAlbums([]);
|
||||
setArtists([]);
|
||||
setPlaylists([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to clear offline data:', err);
|
||||
setError('Failed to clear offline data');
|
||||
throw err;
|
||||
}
|
||||
}, [clearOfflineDataInternal]);
|
||||
|
||||
const value: OfflineNavidromeContextType = {
|
||||
// Data
|
||||
albums,
|
||||
artists,
|
||||
playlists,
|
||||
|
||||
// Loading states
|
||||
isLoading,
|
||||
albumsLoading,
|
||||
artistsLoading,
|
||||
playlistsLoading,
|
||||
|
||||
// Connection state
|
||||
isOnline,
|
||||
isOfflineReady,
|
||||
|
||||
// Error state
|
||||
error,
|
||||
|
||||
// Offline sync status
|
||||
isSyncing,
|
||||
lastSync,
|
||||
pendingOperations,
|
||||
|
||||
// Methods
|
||||
searchMusic,
|
||||
getAlbum,
|
||||
getArtist,
|
||||
getPlaylists: getPlaylistsWrapper,
|
||||
refreshData,
|
||||
|
||||
// Offline-capable operations
|
||||
starItem,
|
||||
unstarItem,
|
||||
createPlaylist,
|
||||
scrobble,
|
||||
|
||||
// Sync management
|
||||
syncLibrary,
|
||||
syncPendingOperations,
|
||||
clearOfflineData
|
||||
};
|
||||
|
||||
return (
|
||||
<OfflineNavidromeContext.Provider value={value}>
|
||||
{children}
|
||||
</OfflineNavidromeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
|
||||
const context = useContext(OfflineNavidromeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
|
||||
interface OfflineNavidromeContextType {
|
||||
// All the original NavidromeContext methods but with offline-first behavior
|
||||
getAlbums: (starred?: boolean) => Promise<Album[]>;
|
||||
getArtists: (starred?: boolean) => Promise<Artist[]>;
|
||||
getSongs: (albumId?: string, artistId?: string) => Promise<Song[]>;
|
||||
getPlaylists: () => Promise<Playlist[]>;
|
||||
|
||||
// Offline-aware operations
|
||||
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<void>;
|
||||
updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise<void>;
|
||||
deletePlaylist: (id: string) => Promise<void>;
|
||||
scrobble: (songId: string) => Promise<void>;
|
||||
|
||||
// Offline state
|
||||
isOfflineMode: boolean;
|
||||
hasPendingOperations: boolean;
|
||||
lastSync: Date | null;
|
||||
}
|
||||
|
||||
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineNavidromeProviderInnerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Inner component that has access to both contexts
|
||||
const OfflineNavidromeProviderInner: React.FC<OfflineNavidromeProviderInnerProps> = ({ children }) => {
|
||||
const navidromeContext = useNavidrome();
|
||||
const offlineLibrary = useOfflineLibrary();
|
||||
|
||||
// Offline-first data retrieval methods
|
||||
const getAlbums = async (starred?: boolean): Promise<Album[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
// Offline mode - get from IndexedDB
|
||||
return await offlineLibrary.getAlbums(starred);
|
||||
}
|
||||
|
||||
try {
|
||||
// Online mode - try server first, fallback to offline
|
||||
const albums = starred
|
||||
? await navidromeContext.api.getAlbums('starred', 1000)
|
||||
: await navidromeContext.api.getAlbums('alphabeticalByName', 1000);
|
||||
return albums;
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getAlbums(starred);
|
||||
}
|
||||
};
|
||||
|
||||
const getArtists = async (starred?: boolean): Promise<Artist[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getArtists(starred);
|
||||
}
|
||||
|
||||
try {
|
||||
const artists = await navidromeContext.api.getArtists();
|
||||
if (starred) {
|
||||
// Filter starred artists from the full list
|
||||
const starredData = await navidromeContext.api.getStarred2();
|
||||
const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []);
|
||||
return artists.filter(artist => starredArtistIds.has(artist.id));
|
||||
}
|
||||
return artists;
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getArtists(starred);
|
||||
}
|
||||
};
|
||||
|
||||
const getSongs = async (albumId?: string, artistId?: string): Promise<Song[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getSongs(albumId, artistId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (albumId) {
|
||||
const { songs } = await navidromeContext.api.getAlbum(albumId);
|
||||
return songs;
|
||||
} else if (artistId) {
|
||||
const { albums } = await navidromeContext.api.getArtist(artistId);
|
||||
const allSongs: Song[] = [];
|
||||
for (const album of albums) {
|
||||
const { songs } = await navidromeContext.api.getAlbum(album.id);
|
||||
allSongs.push(...songs);
|
||||
}
|
||||
return allSongs;
|
||||
} else {
|
||||
return await navidromeContext.getAllSongs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getSongs(albumId, artistId);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaylists = async (): Promise<Playlist[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getPlaylists();
|
||||
}
|
||||
|
||||
try {
|
||||
return await navidromeContext.api.getPlaylists();
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getPlaylists();
|
||||
}
|
||||
};
|
||||
|
||||
// Offline-aware operations (queue for sync when offline)
|
||||
const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.starItem(id, type);
|
||||
// Update offline data immediately
|
||||
await offlineLibrary.starOffline(id, type);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server star failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for sync when back online
|
||||
await offlineLibrary.starOffline(id, type);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'star',
|
||||
entityType: type,
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.unstarItem(id, type);
|
||||
await offlineLibrary.unstarOffline(id, type);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server unstar failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.unstarOffline(id, type);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'unstar',
|
||||
entityType: type,
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const createPlaylist = async (name: string, songIds?: string[]): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
const playlist = await navidromeContext.createPlaylist(name, songIds);
|
||||
await offlineLibrary.createPlaylistOffline(name, songIds || []);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist creation failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create offline
|
||||
await offlineLibrary.createPlaylistOffline(name, songIds || []);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'create_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: 'temp-' + Date.now(),
|
||||
data: { name, songIds: songIds || [] }
|
||||
});
|
||||
};
|
||||
|
||||
const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.updatePlaylist(id, name, comment, songIds);
|
||||
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist update failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'update_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: id,
|
||||
data: { name, comment, songIds }
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id: string): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.deletePlaylist(id);
|
||||
await offlineLibrary.deletePlaylistOffline(id);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist deletion failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.deletePlaylistOffline(id);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'delete_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const scrobble = async (songId: string): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.scrobble(songId);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server scrobble failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: { timestamp: Date.now() }
|
||||
});
|
||||
};
|
||||
|
||||
const contextValue: OfflineNavidromeContextType = {
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSongs,
|
||||
getPlaylists,
|
||||
starItem,
|
||||
unstarItem,
|
||||
createPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
scrobble,
|
||||
isOfflineMode: !offlineLibrary.isOnline,
|
||||
hasPendingOperations: offlineLibrary.stats.pendingOperations > 0,
|
||||
lastSync: offlineLibrary.lastSync
|
||||
};
|
||||
|
||||
return (
|
||||
<OfflineNavidromeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OfflineNavidromeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Main provider component
|
||||
export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<NavidromeProvider>
|
||||
<OfflineNavidromeProviderInner>
|
||||
{children}
|
||||
</OfflineNavidromeProviderInner>
|
||||
</NavidromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use the offline-aware Navidrome context
|
||||
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
|
||||
const context = useContext(OfflineNavidromeContext);
|
||||
if (!context) {
|
||||
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { Wifi, WifiOff, Download, Clock } from 'lucide-react';
|
||||
|
||||
export function OfflineStatusIndicator() {
|
||||
const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary();
|
||||
|
||||
if (!isOnline) {
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<WifiOff size={12} />
|
||||
Offline Mode
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSyncing) {
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Download size={12} className="animate-bounce" />
|
||||
Syncing...
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.pendingOperations > 0) {
|
||||
return (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{stats.pendingOperations} pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Wifi size={12} />
|
||||
Online
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function OfflineLibraryStats() {
|
||||
const { stats, lastSync } = useOfflineLibrary();
|
||||
|
||||
if (!stats.albums && !stats.songs && !stats.artists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>
|
||||
📀 {stats.albums} albums • 🎵 {stats.songs} songs • 👤 {stats.artists} artists
|
||||
</div>
|
||||
{lastSync && (
|
||||
<div>
|
||||
Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -95,7 +95,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
|
||||
{song.coverArt && api && (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 300)}
|
||||
src={api.getCoverArtUrl(song.coverArt, 96)}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
50
app/components/PostHogProvider.tsx
Normal file
50
app/components/PostHogProvider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import posthog from "posthog-js"
|
||||
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
|
||||
function PathnameTracker() {
|
||||
const posthogClient = usePostHog()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (posthogClient) {
|
||||
posthogClient.capture('$pageview', {
|
||||
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
|
||||
})
|
||||
}
|
||||
}, [posthogClient, pathname, searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SuspendedPostHogPageView() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PathnameTracker />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
|
||||
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",
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<SuspendedPostHogPageView />
|
||||
{children}
|
||||
</PHProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
|
||||
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
|
||||
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
|
||||
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
|
||||
import { ThemeProvider } from "../components/ThemeProvider";
|
||||
import { PostHogProvider } from "../components/PostHogProvider";
|
||||
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
||||
import Ihateserverside from "./ihateserverside";
|
||||
import DynamicViewportTheme from "./DynamicViewportTheme";
|
||||
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 }) {
|
||||
// For now, since we're switching to offline-first, we'll handle errors differently
|
||||
// The offline provider will handle connectivity issues automatically
|
||||
const { error } = useNavidrome();
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
|
||||
|
||||
@@ -77,9 +56,10 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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);
|
||||
|
||||
if (shouldShowStartScreen) {
|
||||
return (
|
||||
@@ -100,24 +80,22 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<DynamicViewportTheme />
|
||||
<ThemeColorHandler />
|
||||
<ServiceWorkerRegistration />
|
||||
<NavidromeConfigProvider>
|
||||
<OfflineNavidromeProvider>
|
||||
<NavidromeErrorBoundary>
|
||||
<AudioPlayerProvider>
|
||||
<GlobalSearchProvider>
|
||||
<PostHogProvider>
|
||||
<ThemeProvider>
|
||||
<DynamicViewportTheme />
|
||||
<NavidromeConfigProvider>
|
||||
<NavidromeProvider>
|
||||
<NavidromeErrorBoundary>
|
||||
<AudioPlayerProvider>
|
||||
<Ihateserverside>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
{children}
|
||||
</Ihateserverside>
|
||||
<WhatsNewPopup />
|
||||
</GlobalSearchProvider>
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeErrorBoundary>
|
||||
</OfflineNavidromeProvider>
|
||||
</NavidromeConfigProvider>
|
||||
</ThemeProvider>
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeErrorBoundary>
|
||||
</NavidromeProvider>
|
||||
</NavidromeConfigProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SettingsManagement() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="py-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
|
||||
@@ -153,7 +153,7 @@ export function SidebarCustomization() {
|
||||
|
||||
|
||||
return (
|
||||
<Card className="py-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sidebar Customization</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -1,125 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Play, Heart, Music, Shuffle } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { UserProfile } from './UserProfile';
|
||||
|
||||
interface SongRecommendationsProps {
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
const offline = useOfflineNavidrome();
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||
const isMobile = useIsMobile();
|
||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
||||
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Memoize the greeting to prevent recalculation
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
}, []);
|
||||
|
||||
// Memoized callbacks to prevent re-renders
|
||||
const handleImageLoad = useCallback(() => {
|
||||
// Image loaded - no state update needed to prevent re-renders
|
||||
}, []);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
// Image error - no state update needed to prevent re-renders
|
||||
}, []);
|
||||
// Get greeting based on time of day
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecommendations = async () => {
|
||||
if (!api || !isConnected) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getNavidromeAPI();
|
||||
const isOnline = !offline.isOfflineMode && !!api;
|
||||
// Get random albums and extract songs from them
|
||||
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
|
||||
const allSongs: Song[] = [];
|
||||
|
||||
if (isOnline && api) {
|
||||
// Online: use server-side recommendations
|
||||
const randomAlbums = await api.getAlbums('random', 10);
|
||||
if (isMobile) {
|
||||
setRecommendedAlbums(randomAlbums.slice(0, 6));
|
||||
} else {
|
||||
const allSongs: Song[] = [];
|
||||
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
|
||||
try {
|
||||
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
|
||||
allSongs.push(...albumSongs);
|
||||
} catch (error) {
|
||||
console.error('Failed to get album songs:', error);
|
||||
}
|
||||
}
|
||||
const shuffled = allSongs.sort(() => Math.random() - 0.5);
|
||||
const recommendations = shuffled.slice(0, 6);
|
||||
setRecommendedSongs(recommendations);
|
||||
const states: Record<string, boolean> = {};
|
||||
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);
|
||||
// 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);
|
||||
allSongs.push(...albumSongs);
|
||||
} catch (error) {
|
||||
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 and image loading states
|
||||
const states: Record<string, boolean> = {};
|
||||
const imageStates: Record<string, boolean> = {};
|
||||
recommendations.forEach((song: Song) => {
|
||||
states[song.id] = !!song.starred;
|
||||
imageStates[song.id] = true; // Start with loading state
|
||||
});
|
||||
setSongStates(states);
|
||||
setImageLoadingStates(imageStates);
|
||||
} catch (error) {
|
||||
console.error('Failed to load recommendations:', error);
|
||||
setRecommendedAlbums([]);
|
||||
setRecommendedSongs([]);
|
||||
console.error('Failed to load song recommendations:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRecommendations();
|
||||
}, [offline, isMobile]);
|
||||
}, [api, isConnected]);
|
||||
|
||||
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,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.albumId || '',
|
||||
duration: song.duration || 0,
|
||||
coverArt,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
starred: !!song.starred
|
||||
};
|
||||
await playTrack(track, true);
|
||||
@@ -128,56 +92,17 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
try {
|
||||
const api = getNavidromeAPI();
|
||||
let albumSongs: Song[] = [];
|
||||
if (api) {
|
||||
albumSongs = await api.getAlbumSongs(album.id);
|
||||
} else {
|
||||
albumSongs = await offline.getSongs(album.id);
|
||||
}
|
||||
if (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: 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffleAll = async () => {
|
||||
if (isMobile && recommendedAlbums.length === 0) return;
|
||||
if (!isMobile && recommendedSongs.length === 0) return;
|
||||
if (recommendedSongs.length === 0) return;
|
||||
|
||||
// Enable shuffle if not already on
|
||||
if (!shuffle) {
|
||||
toggleShuffle();
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
// Play a random album
|
||||
const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
|
||||
await handlePlayAlbum(randomAlbum);
|
||||
} else {
|
||||
// Play a random song from recommendations
|
||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
||||
await handlePlaySong(randomSong);
|
||||
}
|
||||
// Play a random song from recommendations
|
||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
||||
await handlePlaySong(randomSong);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number): string => {
|
||||
@@ -193,19 +118,11 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,153 +135,95 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
{greeting}{userName ? `, ${userName}` : ''}!
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
|
||||
Here are some songs you might enjoy
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile User Profile */}
|
||||
{isMobile && <UserProfile variant="mobile" />}
|
||||
|
||||
{/* Shuffle All Button (Desktop only) */}
|
||||
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
|
||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
Shuffle All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{recommendedSongs.length > 0 && (
|
||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
Shuffle All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
/* Mobile: Show albums in 3x2 grid */
|
||||
recommendedAlbums.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{recommendedAlbums.map((album) => (
|
||||
<div key={album.id} className="space-y-2">
|
||||
<Link
|
||||
href={`/album/${album.id}`}
|
||||
className="group cursor-pointer block"
|
||||
>
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
|
||||
<Image
|
||||
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 33vw, 200px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
{recommendedSongs.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedSongs.map((song) => (
|
||||
<Card
|
||||
key={song.id}
|
||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<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 ? (
|
||||
<>
|
||||
{imageLoadingStates[song.id] && (
|
||||
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 100)}
|
||||
alt={song.title}
|
||||
fill
|
||||
className={`object-cover transition-opacity duration-300 ${
|
||||
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
sizes="48px"
|
||||
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music className="w-8 h-8 text-muted-foreground" />
|
||||
<Music className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!imageLoadingStates[song.id] && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href={`/album/${album.id}`}
|
||||
className="font-medium text-sm truncate hover:underline block"
|
||||
>
|
||||
{album.name}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/artist/${album.artistId || album.artist}`}
|
||||
className="text-xs text-muted-foreground truncate hover:underline block"
|
||||
>
|
||||
{album.artist}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No albums available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
) : (
|
||||
/* Desktop: Show songs in original format */
|
||||
recommendedSongs.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedSongs.map((song) => (
|
||||
<Card
|
||||
key={song.id}
|
||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<CardContent className="px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/artist/${song.artistId}`}
|
||||
className="hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{song.artist}
|
||||
</Link>
|
||||
{song.duration && (
|
||||
<>
|
||||
<Image
|
||||
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
|
||||
alt={song.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(song.duration)}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/artist/${song.artistId}`}
|
||||
className="hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{song.artist}
|
||||
</Link>
|
||||
{song.duration && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(song.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{songStates[song.id] && (
|
||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No songs available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
{songStates[song.id] && (
|
||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No songs available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
'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 “{query}”</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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
|
||||
|
||||
export default function ThemeColorHandler() {
|
||||
useViewportThemeColor();
|
||||
return null;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { User as NavidromeUser } from '@/lib/navidrome';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface UserProfileProps {
|
||||
variant?: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserInfo = async () => {
|
||||
if (!api || !isConnected) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await api.getUserInfo();
|
||||
setUserInfo(user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user info:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserInfo();
|
||||
}, [api, isConnected]);
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear Navidrome config and reload
|
||||
localStorage.removeItem('navidrome-config');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (!userInfo) {
|
||||
if (variant === 'desktop') {
|
||||
return (
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gravatarUrl = userInfo.email
|
||||
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
|
||||
: null;
|
||||
|
||||
if (variant === 'desktop') {
|
||||
// Desktop: Only show profile icon
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{userInfo.username}</p>
|
||||
{userInfo.email && (
|
||||
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
} else {
|
||||
// Mobile: Show only icon with dropdown
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{userInfo.username}</p>
|
||||
{userInfo.email && (
|
||||
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// Current app version from package.json
|
||||
const APP_VERSION = '2025.07.31';
|
||||
const APP_VERSION = '2025.07.10';
|
||||
|
||||
// Changelog data - add new versions at the top
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: '2025.07.31',
|
||||
title: 'July End of Month Update',
|
||||
changes: [
|
||||
'Native support for moblie devices (using pwa)',
|
||||
],
|
||||
fixes: [
|
||||
'Fixed issue with mobile navigation bar not displaying correctly',
|
||||
'Improved performance on mobile devices',
|
||||
'Resolved layout issues on smaller screens',
|
||||
'Fixed audio player controls not responding on mobile',
|
||||
'Improved touch interactions for better usability',
|
||||
'Fixed issue with album artwork not loading on mobile',
|
||||
'Resolved bug with search functionality on mobile devices',
|
||||
'Improved caching for faster load times on mobile',
|
||||
],
|
||||
breaking: [
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '2025.07.10',
|
||||
title: 'July Major Update',
|
||||
@@ -47,6 +30,7 @@ const CHANGELOG = [
|
||||
'Enhanced Home page layout and content',
|
||||
'Themes updated to use OKLCH (from HSL)',
|
||||
'All themes updated (light themes look similar)',
|
||||
'Caching system added (incomplete)',
|
||||
'Skeleton loading added across all pages'
|
||||
],
|
||||
fixes: [
|
||||
@@ -205,86 +189,65 @@ export function WhatsNewPopup() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Dialog content */}
|
||||
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 px-6 pt-4 shrink-0">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
<div className="space-y-6">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer button */}
|
||||
<div className="flex justify-center p-6 pt-4 shrink-0">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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 {
|
||||
@@ -18,22 +17,17 @@ 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";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect } 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, Download } from "lucide-react";
|
||||
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
|
||||
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||
import { OfflineIndicator } from "@/app/components/OfflineIndicator";
|
||||
|
||||
interface AlbumArtworkProps extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'onDrop'
|
||||
> {
|
||||
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
album: Album
|
||||
aspectRatio?: "portrait" | "square"
|
||||
width?: number
|
||||
@@ -49,44 +43,16 @@ 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();
|
||||
|
||||
// Memoize cover art URL with dynamic sizing
|
||||
const coverArtUrl = useMemo(() => {
|
||||
if (!api || !album.coverArt) return '/default-user.jpg';
|
||||
|
||||
// Use width prop or default size for optimization
|
||||
const imageSize = width || height || 300;
|
||||
return api.getCoverArtUrl(album.coverArt, imageSize);
|
||||
}, [api, album.coverArt, width, height]);
|
||||
|
||||
// Use callback to prevent function recreation on every render
|
||||
const handleImageLoad = useCallback(() => {
|
||||
// Image loaded successfully - no state update needed
|
||||
}, []);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
// Image failed to load - could set error state if needed
|
||||
}, []);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
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);
|
||||
};
|
||||
@@ -114,7 +80,7 @@ export function AlbumArtwork({
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
@@ -139,61 +105,68 @@ export function AlbumArtwork({
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get cover art URL with proper fallback
|
||||
const coverArtUrl = album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
|
||||
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()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api && !offline.isOfflineMode ? (
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
alt={album.name}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-all"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<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
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt)}
|
||||
alt={album.name}
|
||||
fill
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!imageLoading && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
{imageLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-3/4 bg-muted animate-pulse rounded mb-2" />
|
||||
<div className="h-4 w-1/2 bg-muted animate-pulse rounded mb-1" />
|
||||
<div className="h-3 w-2/3 bg-muted animate-pulse rounded" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold truncate">{album.name}</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
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
@@ -250,7 +223,6 @@ export function AlbumArtwork({
|
||||
<ContextMenuItem>Share</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -89,13 +88,6 @@ 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"
|
||||
@@ -126,7 +118,6 @@ export function ArtistIcon({
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={handleStar}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Menu } from "@/app/components/menu";
|
||||
import { Sidebar } from "@/app/components/sidebar";
|
||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||
import { AudioPlayer } from "./AudioPlayer";
|
||||
import { BottomNavigation } from './BottomNavigation';
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
|
||||
|
||||
@@ -97,74 +96,48 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Layout */}
|
||||
<div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
{/* <div className="shrink-0 bg-background border-b w-full">
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* Main Content Area with bottom padding for audio player and bottom nav */}
|
||||
<div className="flex-1 overflow-y-auto pb-40">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation for Mobile */}
|
||||
<BottomNavigation />
|
||||
|
||||
<Toaster />
|
||||
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
<div
|
||||
className="sticky z-10 bg-background border-b w-full"
|
||||
style={{
|
||||
left: 'env(titlebar-area-x, 0)',
|
||||
top: 'env(titlebar-area-y, 0)',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
<div
|
||||
className="sticky z-10 bg-background border-b w-full"
|
||||
style={{
|
||||
left: 'env(titlebar-area-x, 0)',
|
||||
top: 'env(titlebar-area-y, 0)',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden w-full">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-16 shrink-0 border-r transition-all duration-200">
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
visible={isSidebarVisible}
|
||||
favoriteAlbums={favoriteAlbums}
|
||||
onRemoveFavoriteAlbum={removeFavoriteAlbum}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto min-w-0">
|
||||
<div>{children}</div>
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden w-full">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-16 shrink-0 border-r transition-all duration-200">
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
visible={isSidebarVisible}
|
||||
favoriteAlbums={favoriteAlbums}
|
||||
onRemoveFavoriteAlbum={removeFavoriteAlbum}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto min-w-0">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
{/* Single Shared Audio Player - shows on all layouts */}
|
||||
<AudioPlayer />
|
||||
</>
|
||||
{/* Floating Audio Player */}
|
||||
{isStatusBarVisible && (
|
||||
<AudioPlayer />
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
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 { Github, Mail } from "lucide-react"
|
||||
import {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
@@ -30,35 +28,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Search,
|
||||
Home,
|
||||
List,
|
||||
Radio,
|
||||
Users,
|
||||
Disc,
|
||||
Music,
|
||||
Heart,
|
||||
Grid3X3,
|
||||
Clock,
|
||||
Settings,
|
||||
Circle
|
||||
} from "lucide-react";
|
||||
|
||||
interface MenuProps {
|
||||
toggleSidebar: () => void;
|
||||
@@ -71,28 +43,9 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const { isConnected } = useNavidrome();
|
||||
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 = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/search', label: 'Search', icon: Search },
|
||||
{ href: '/library/albums', label: 'Albums', icon: Disc },
|
||||
{ href: '/library/artists', label: 'Artists', icon: Users },
|
||||
{ href: '/library/songs', label: 'Songs', icon: Circle },
|
||||
{ href: '/library/playlists', label: 'Playlists', icon: Music },
|
||||
{ href: '/favorites', label: 'Favorites', icon: Heart },
|
||||
{ href: '/queue', label: 'Queue', icon: List },
|
||||
{ href: '/radio', label: 'Radio', icon: Radio },
|
||||
{ href: '/browse', label: 'Browse', icon: Grid3X3 },
|
||||
{ href: '/history', label: 'History', icon: Clock },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
// For this demo, we'll show connection status instead of user auth
|
||||
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
||||
@@ -159,35 +112,28 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
|
||||
{isMobile ? (
|
||||
// hey bear!
|
||||
// nothing
|
||||
null
|
||||
) : (
|
||||
/* Desktop Navigation */
|
||||
<Menubar
|
||||
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
||||
style={{
|
||||
minWidth: 0,
|
||||
WebkitAppRegion: "drag"
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => router.push('/settings')}>
|
||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => isClient && window.close()}>
|
||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<Menubar
|
||||
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
||||
style={{
|
||||
minWidth: 0,
|
||||
WebkitAppRegion: "drag"
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => router.push('/settings')}>
|
||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => isClient && window.close()}>
|
||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="relative">File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
@@ -333,24 +279,6 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
</MenubarMenu>
|
||||
</div>
|
||||
</Menubar>
|
||||
)}
|
||||
|
||||
{/* User Profile and Search - Desktop only */}
|
||||
{!isMobile && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
||||
>
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
||||
alt={album.name}
|
||||
width={16}
|
||||
height={16}
|
||||
@@ -165,7 +165,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
||||
>
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
||||
alt={album.name}
|
||||
width={16}
|
||||
height={16}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@@ -45,7 +45,20 @@ export function LoginForm({
|
||||
return true;
|
||||
});
|
||||
|
||||
// New settings - removed sidebar and standalone lastfm options
|
||||
// New settings
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if Navidrome is configured via environment variables
|
||||
const hasEnvConfig = React.useMemo(() => {
|
||||
@@ -174,6 +187,8 @@ export function LoginForm({
|
||||
const handleFinishSetup = () => {
|
||||
// Save all settings
|
||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
|
||||
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
|
||||
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||
@@ -237,7 +252,7 @@ export function LoginForm({
|
||||
if (step === 'settings') {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className='py-5'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaPalette className="w-5 h-5" />
|
||||
@@ -271,6 +286,29 @@ export function LoginForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Settings */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaBars className="w-4 h-4" />
|
||||
Sidebar Layout
|
||||
</Label>
|
||||
<Select
|
||||
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can always toggle this later using the button in the sidebar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last.fm Scrobbling */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -296,6 +334,31 @@ export function LoginForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Standalone Last.fm */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaLastfm className="w-4 h-4" />
|
||||
Standalone Last.fm (Advanced)
|
||||
</Label>
|
||||
<Select
|
||||
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{standaloneLastfmEnabled
|
||||
? "Direct Last.fm API integration (configure in Settings later)"
|
||||
: "Use only Navidrome's Last.fm integration"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleFinishSetup} className="w-full">
|
||||
<FaCheck className="w-4 h-4 mr-2" />
|
||||
@@ -320,7 +383,7 @@ export function LoginForm({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="py-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
|
||||
@@ -58,7 +58,7 @@ const FavoritesPage = () => {
|
||||
artistId: song.artistId,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
});
|
||||
};
|
||||
@@ -78,7 +78,7 @@ const FavoritesPage = () => {
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
@@ -201,7 +201,7 @@ const FavoritesPage = () => {
|
||||
<div className="w-12 h-12 relative shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 1200)}
|
||||
src={api.getCoverArtUrl(song.coverArt)}
|
||||
alt={song.album}
|
||||
fill
|
||||
className="rounded object-cover"
|
||||
|
||||
198
app/globals.css
198
app/globals.css
@@ -88,18 +88,6 @@
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Hide scrollbars on mobile */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -828,170 +816,34 @@
|
||||
---break---
|
||||
*/
|
||||
|
||||
/* Mobile-specific optimizations */
|
||||
@media (max-width: 767px) {
|
||||
/* Improve touch targets for mobile */
|
||||
button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Better touch feedback */
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Ensure proper viewport behavior */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for mobile */
|
||||
.overflow-y-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile audio player specific */
|
||||
.mobile-audio-player {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Prevent horizontal scroll */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/*
|
||||
|
||||
will delete after the new theme replaces the old one
|
||||
since the new theme already has the sidebar colors defined
|
||||
|
||||
:root {
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
|
||||
/* Safe area support for mobile devices */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
|
||||
}
|
||||
|
||||
.mobile-safe-bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* Touch-optimized navigation */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Bottom navigation z-index fix */
|
||||
.bottom-nav {
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
/* Audio player above bottom nav */
|
||||
.mobile-audio-above-nav {
|
||||
z-index: 50;
|
||||
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
/* Mobile Audio Player Styles */
|
||||
.mobile-audio-player {
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-audio-player button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom on input focus */
|
||||
@media screen and (max-width: 767px) {
|
||||
input[type="range"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Improve button touch targets */
|
||||
.mobile-audio-player button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Better focus states for accessibility */
|
||||
button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Improved animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Safe area insets for mobile devices */
|
||||
@supports (padding: max(0px)) {
|
||||
.mobile-safe-bottom {
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-safe-top {
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress bar improvements for mobile */
|
||||
@media (max-width: 767px) {
|
||||
.progress-mobile {
|
||||
height: 3px;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.progress-mobile::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
margin-top: -6px;
|
||||
}
|
||||
}
|
||||
.dark {
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
} */
|
||||
|
||||
/*
|
||||
---break---
|
||||
*/
|
||||
|
||||
/* Mobile Bottom Navigation Styles */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
|
||||
}
|
||||
|
||||
.mobile-safe-bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-audio-above-nav {
|
||||
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
*/
|
||||
@@ -10,7 +10,6 @@ import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react';
|
||||
import ListeningStreakCard from '@/app/components/ListeningStreakCard';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -79,10 +78,6 @@ export default function HistoryPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<ListeningStreakCard />
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||
<TabsContent value="music" className="border-none p-0 outline-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -26,36 +26,6 @@ export const metadata = {
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
other: {
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'apple-mobile-web-app-status-bar-style': 'black-translucent',
|
||||
'format-detection': 'telephone=no',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: '48x48' },
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-touch-icon-precomposed.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
const geistSans = localFont({
|
||||
@@ -77,7 +47,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Music, Users, Disc, ListMusic, Heart, Play } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import NavidromeAPI from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
interface LibraryStats {
|
||||
albums: number;
|
||||
artists: number;
|
||||
songs: number;
|
||||
playlists: number;
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
|
||||
const [stats, setStats] = useState<LibraryStats>({ albums: 0, artists: 0, songs: 0, playlists: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [api, setApi] = useState<NavidromeAPI | null>(null);
|
||||
const { playAlbum } = useAudioPlayer();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
const loadLibraryData = async () => {
|
||||
try {
|
||||
const navidromeApi = getNavidromeAPI();
|
||||
if (!navidromeApi) {
|
||||
console.error('Navidrome API not available');
|
||||
return;
|
||||
}
|
||||
setApi(navidromeApi);
|
||||
|
||||
// Load recent albums
|
||||
const albumsData = await navidromeApi.getAlbums('newest', 4, 0);
|
||||
setRecentAlbums(albumsData || []);
|
||||
|
||||
// Load library stats
|
||||
const [allAlbums, allArtists, allPlaylists] = await Promise.all([
|
||||
navidromeApi.getAlbums('alphabeticalByName', 1, 0), // Just to get count
|
||||
navidromeApi.getArtists(),
|
||||
navidromeApi.getPlaylists()
|
||||
]);
|
||||
|
||||
setStats({
|
||||
albums: allAlbums?.length || 0,
|
||||
artists: allArtists?.length || 0,
|
||||
songs: 0, // We don't have a direct method for this
|
||||
playlists: allPlaylists?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load library data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLibraryData();
|
||||
}, []);
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
try {
|
||||
await playAlbum(album.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const libraryLinks = [
|
||||
{
|
||||
href: '/library/albums',
|
||||
label: 'Albums',
|
||||
icon: Disc,
|
||||
description: 'Browse all albums',
|
||||
count: stats.albums
|
||||
},
|
||||
{
|
||||
href: '/library/artists',
|
||||
label: 'Artists',
|
||||
icon: Users,
|
||||
description: 'Discover artists',
|
||||
count: stats.artists
|
||||
},
|
||||
{
|
||||
href: '/library/songs',
|
||||
label: 'Songs',
|
||||
icon: Music,
|
||||
description: 'All your music',
|
||||
count: stats.songs
|
||||
},
|
||||
{
|
||||
href: '/library/playlists',
|
||||
label: 'Playlists',
|
||||
icon: ListMusic,
|
||||
description: 'Your playlists',
|
||||
count: stats.playlists
|
||||
},
|
||||
{
|
||||
href: '/favorites',
|
||||
label: 'Favorites',
|
||||
icon: Heart,
|
||||
description: 'Starred music',
|
||||
count: 0
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 pb-20 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Your Library</h1>
|
||||
|
||||
{/* Loading skeleton for library links */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Browse</h2>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-muted rounded-lg h-16"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton for recent albums */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-muted rounded-lg aspect-square mb-2"></div>
|
||||
<div className="bg-muted h-4 rounded mb-1"></div>
|
||||
<div className="bg-muted h-3 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 pb-20 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Your Library</h1>
|
||||
|
||||
{/* Library Navigation - Always at top */}
|
||||
<div>
|
||||
{/* <h2 className="text-lg font-semibold mb-3">Browse</h2> */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{libraryLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Icon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{link.label}</h3>
|
||||
<p className="text-sm text-muted-foreground">{link.description}</p>
|
||||
</div>
|
||||
{link.count > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{link.count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recently Added Albums - At bottom on mobile, after Browse on desktop */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{recentAlbums.map((album) => (
|
||||
<Card key={album.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<CardContent className="p-3">
|
||||
<Link href={`/album/${album.id}`}>
|
||||
<div className="relative aspect-square mb-2">
|
||||
<Image
|
||||
src={album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 300) : '/default-user.jpg'}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
className="object-cover rounded-lg"
|
||||
sizes="(max-width: 768px) 50vw, 200px"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayAlbum(album);
|
||||
}}
|
||||
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Play className="w-8 h-8 text-white fill-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-sm truncate hover:underline">{album.name}</h3>
|
||||
<Link
|
||||
href={`/artist/${album.artistId || album.artist}`}
|
||||
className="text-xs text-muted-foreground truncate hover:underline block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{album.artist}
|
||||
</Link>
|
||||
{/* {album.year && (
|
||||
<p className="text-xs text-muted-foreground">{album.year}</p>
|
||||
)} */}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const PlaylistsPage: React.FC = () => {
|
||||
<ScrollArea>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => {
|
||||
const playlistCoverUrl = playlist.coverArt && api
|
||||
? api.getCoverArtUrl(playlist.coverArt, 600)
|
||||
? api.getCoverArtUrl(playlist.coverArt, 200)
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function SongsPage() {
|
||||
|
||||
setFilteredSongs(filtered);
|
||||
}, [songs, searchQuery, sortBy, sortDirection]);
|
||||
const handlePlayClick = (song: Song) => {
|
||||
const handlePlaySong = (song: Song) => {
|
||||
if (!api) {
|
||||
console.error('Navidrome API not available');
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export default function SongsPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : 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, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -222,7 +222,7 @@ export default function SongsPage() {
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
|
||||
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
|
||||
}`}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
@@ -240,7 +240,7 @@ export default function SongsPage() {
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
115
app/manifest.ts
Normal file
115
app/manifest.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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'
|
||||
}
|
||||
],
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
114
app/page.tsx
114
app/page.tsx
@@ -4,86 +4,59 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import { Tabs, TabsContent } from '../components/ui/tabs';
|
||||
import { AlbumArtwork } from './components/album-artwork';
|
||||
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
|
||||
import { useNavidrome } from './components/NavidromeContext';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { Album } from '@/lib/navidrome';
|
||||
import { useNavidromeConfig } from './components/NavidromeConfigContext';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useAudioPlayer } from './components/AudioPlayerContext';
|
||||
import { SongRecommendations } from './components/SongRecommendations';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
|
||||
import CompactListeningStreak from './components/CompactListeningStreak';
|
||||
|
||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||
|
||||
function MusicPageContent() {
|
||||
// Offline-first provider (falls back to offline data when not connected)
|
||||
const offline = useOfflineNavidrome();
|
||||
const { albums, isLoading, api, isConnected } = useNavidrome();
|
||||
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(() => {
|
||||
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([]);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setAlbumsLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, [offline]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
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));
|
||||
setRecentAlbums(recent);
|
||||
setNewestAlbums(newest);
|
||||
}
|
||||
}, [albums]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFavoriteAlbums = async () => {
|
||||
if (!api || !isConnected) return;
|
||||
|
||||
setFavoritesLoading(true);
|
||||
try {
|
||||
const starred = await offline.getAlbums(true);
|
||||
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
|
||||
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
|
||||
setFavoriteAlbums(starredAlbums);
|
||||
} catch (error) {
|
||||
console.error('Failed to load favorite albums (offline-first):', error);
|
||||
if (mounted) setFavoriteAlbums([]);
|
||||
console.error('Failed to load favorite albums:', error);
|
||||
} finally {
|
||||
if (mounted) setFavoritesLoading(false);
|
||||
setFavoritesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFavoriteAlbums();
|
||||
return () => { mounted = false; };
|
||||
}, [offline]);
|
||||
}, [api, isConnected]);
|
||||
|
||||
// Handle PWA shortcuts
|
||||
useEffect(() => {
|
||||
const action = searchParams.get('action');
|
||||
if (!action || shortcutProcessed) return;
|
||||
if (!action || shortcutProcessed || !api || !isConnected) return;
|
||||
|
||||
const handleShortcuts = async () => {
|
||||
try {
|
||||
@@ -117,13 +90,12 @@ function MusicPageContent() {
|
||||
// Add remaining albums to queue
|
||||
for (let i = 1; i < shuffledAlbums.length; i++) {
|
||||
try {
|
||||
const songs = await offline.getSongs(shuffledAlbums[i].id);
|
||||
const api = getNavidromeAPI();
|
||||
songs.forEach((song: Song) => {
|
||||
const albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id);
|
||||
albumSongs.forEach(song => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
@@ -134,7 +106,7 @@ function MusicPageContent() {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load album tracks (offline-first):', error);
|
||||
console.error('Failed to load album tracks:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,13 +128,12 @@ function MusicPageContent() {
|
||||
// Add remaining albums to queue
|
||||
for (let i = 1; i < shuffledFavorites.length; i++) {
|
||||
try {
|
||||
const songs = await offline.getSongs(shuffledFavorites[i].id);
|
||||
const api = getNavidromeAPI();
|
||||
songs.forEach((song: Song) => {
|
||||
const albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id);
|
||||
albumSongs.forEach(song => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
@@ -173,7 +144,7 @@ function MusicPageContent() {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load album tracks (offline-first):', error);
|
||||
console.error('Failed to load album tracks:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +159,7 @@ function MusicPageContent() {
|
||||
// Delay to ensure data is loaded
|
||||
const timeout = setTimeout(handleShortcuts, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
|
||||
}, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
|
||||
|
||||
// Try to get user name from navidrome context, fallback to 'user'
|
||||
let userName = '';
|
||||
@@ -201,29 +172,10 @@ function MusicPageContent() {
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 w-full">
|
||||
{/* Connection status (offline indicator) */}
|
||||
{!offline.isOfflineMode ? null : (
|
||||
<div className="mb-4">
|
||||
<OfflineStatusIndicator />
|
||||
</div>
|
||||
)}
|
||||
{/* Offline empty state when nothing is cached */}
|
||||
{offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && (
|
||||
<div className="mb-6 p-4 border rounded-lg bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings → Offline Library to sync your library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Song Recommendations Section */}
|
||||
<div className="mb-8">
|
||||
<SongRecommendations userName={userName} />
|
||||
</div>
|
||||
|
||||
{/* Listening Streak Section - Only shown when 3+ days streak */}
|
||||
<div className="mb-6">
|
||||
<CompactListeningStreak />
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||
@@ -242,7 +194,7 @@ function MusicPageContent() {
|
||||
<div className="relative">
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{albumsLoading ? (
|
||||
{isLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="w-[220px] shrink-0 space-y-3">
|
||||
@@ -329,7 +281,7 @@ function MusicPageContent() {
|
||||
<div className="relative">
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{albumsLoading ? (
|
||||
{isLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="w-[220px] shrink-0 space-y-3">
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function PlaylistPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : 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, 300) : 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, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -209,7 +209,7 @@ export default function PlaylistPage() {
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
@@ -3,161 +3,14 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||
import { useAudioPlayer } 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, 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>
|
||||
);
|
||||
}
|
||||
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
|
||||
|
||||
const QueuePage: React.FC = () => {
|
||||
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 { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -254,29 +107,67 @@ 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) => (
|
||||
<SortableQueueItem
|
||||
key={`${track.id}-${index}`}
|
||||
track={track}
|
||||
index={index}
|
||||
onPlay={() => skipToTrackInQueue(index)}
|
||||
onRemove={() => removeTrackFromQueue(index)}
|
||||
formatDuration={formatDuration}
|
||||
<div className="space-y-1">
|
||||
{queue.map((track, index) => (
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ 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() {
|
||||
@@ -52,31 +51,6 @@ 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');
|
||||
@@ -162,29 +136,25 @@ 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) => (
|
||||
<ArtistContextMenu
|
||||
key={artist.id}
|
||||
artistId={artist.id}
|
||||
artistName={artist.name}
|
||||
>
|
||||
<ArtistIcon
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
className="shrink-0 overflow-hidden"
|
||||
size={190}
|
||||
/>
|
||||
</ArtistContextMenu>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
{/* broken for now */}
|
||||
|
||||
{/* Albums */}
|
||||
{searchResults.albums.length > 0 && (
|
||||
@@ -193,19 +163,14 @@ export default function SearchPage() {
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{searchResults.albums.map((album) => (
|
||||
<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>
|
||||
<AlbumArtwork
|
||||
key={album.id}
|
||||
album={album}
|
||||
className="shrink-0 w-48"
|
||||
aspectRatio="square"
|
||||
width={192}
|
||||
height={192}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
@@ -218,62 +183,54 @@ 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) => {
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
className="hidden group-hover:flex h-8 w-8 p-0"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Song Cover */}
|
||||
<div className="shrink-0">
|
||||
<Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{song.artist} • {song.album}</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.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={() => handleAddToQueue(song)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TrackContextMenu>
|
||||
);
|
||||
})}
|
||||
{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">
|
||||
<div className="w-8 text-center text-sm text-muted-foreground">
|
||||
<span className="group-hover:hidden">{index + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
className="hidden group-hover:flex h-8 w-8 p-0"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Song Cover */}
|
||||
<div className="shrink-0"> <Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{song.artist} • {song.album}</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.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={() => handleAddToQueue(song)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{searchResults.songs.length > 10 && (
|
||||
<div className="text-center pt-4">
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,11 +13,9 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
|
||||
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
||||
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
||||
import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager';
|
||||
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
|
||||
import { Settings, ExternalLink, Tag } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CacheManagement } from '@/app/components/CacheManagement';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
||||
import { Settings, ExternalLink } from 'lucide-react';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { theme, setTheme, mode, setMode } = useTheme();
|
||||
@@ -26,7 +23,6 @@ const SettingsPage = () => {
|
||||
const { toast } = useToast();
|
||||
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
|
||||
const { shortcutType, updateShortcutType } = useSidebarShortcuts();
|
||||
const audioPlayer = useAudioPlayer();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serverUrl: '',
|
||||
@@ -62,7 +58,6 @@ 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(() => {
|
||||
@@ -98,12 +93,6 @@ 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) {
|
||||
@@ -274,43 +263,6 @@ 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({
|
||||
@@ -401,7 +353,7 @@ const SettingsPage = () => {
|
||||
style={{ columnFill: 'balance' }}>
|
||||
|
||||
{!hasEnvConfig && (
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
@@ -490,7 +442,7 @@ const SettingsPage = () => {
|
||||
)}
|
||||
|
||||
{hasEnvConfig && (
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
@@ -517,30 +469,7 @@ 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">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
@@ -618,7 +547,7 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
@@ -673,7 +602,7 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <Card className="mb-6 break-inside-avoid py-5">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
@@ -766,7 +695,7 @@ const SettingsPage = () => {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
</Card>
|
||||
|
||||
{/* Sidebar Customization */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
@@ -778,17 +707,12 @@ const SettingsPage = () => {
|
||||
<SettingsManagement />
|
||||
</div>
|
||||
|
||||
{/* Offline Library Management */}
|
||||
{/* Cache Management */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
<EnhancedOfflineManager />
|
||||
<CacheManagement />
|
||||
</div>
|
||||
|
||||
{/* Auto-Tagging Settings */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
<AutoTaggingSettings />
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -837,88 +761,7 @@ 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">
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -946,47 +789,6 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Debug Section - Development Only */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Card className="mb-6 break-inside-avoid py-5 border-orange-200 bg-orange-50/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-orange-700">
|
||||
<Settings className="w-5 h-5" />
|
||||
Debug Tools
|
||||
</CardTitle>
|
||||
<CardDescription className="text-orange-600">
|
||||
Development-only debugging utilities
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Save Navidrome config before clearing
|
||||
const navidromeConfig = localStorage.getItem('navidrome-config');
|
||||
|
||||
// Clear all localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Restore Navidrome config
|
||||
if (navidromeConfig) {
|
||||
localStorage.setItem('navidrome-config', navidromeConfig);
|
||||
}
|
||||
|
||||
// Reload page to reset state
|
||||
window.location.reload();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full bg-orange-100 border-orange-300 text-orange-700 hover:bg-orange-200"
|
||||
>
|
||||
Clear All Data (Keep Navidrome Config)
|
||||
</Button>
|
||||
<p className="text-xs text-orange-600 mt-2">
|
||||
This will clear all localStorage data except your Navidrome server configuration, then reload the page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-0 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,18 @@ version: '3.8'
|
||||
services:
|
||||
mice:
|
||||
container_name: mice-public
|
||||
image: sillyangel/mice:dev-latest
|
||||
image: sillyangel/mice:latest
|
||||
ports:
|
||||
- "40625:40625"
|
||||
environment:
|
||||
# Navidrome Server Configuration
|
||||
# - NAVIDROME_URL=http://navidrome:4533
|
||||
# - NAVIDROME_USERNAME=user
|
||||
# - NAVIDROME_PASSWORD=password
|
||||
- NAVIDROME_URL=https://navi.sillyangel.dev
|
||||
- NAVIDROME_USERNAME=kryptonite
|
||||
- NAVIDROME_PASSWORD=kryptonite
|
||||
|
||||
# PostHog Analytics
|
||||
- POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
|
||||
- POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# Application Port
|
||||
- PORT=40625
|
||||
@@ -23,24 +27,3 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
navidrome:
|
||||
container_name: navidrome
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANINTERVAL=1m
|
||||
- ND_LOGLEVEL=info
|
||||
- ND_SESSIONTIMEOUT=24h
|
||||
- ND_PORT=4533
|
||||
# - ND_BASEURL=/navidrome
|
||||
# - ND_MUSICFOLDER=/music
|
||||
volumes:
|
||||
- navidrome_data:/data
|
||||
- navidrome_music:/music
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
navidrome_data:
|
||||
navidrome_music:
|
||||
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
- NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||
- NEXT_PUBLIC_NAVIDROME_USERNAME=admin
|
||||
- NEXT_PUBLIC_NAVIDROME_PASSWORD=admin
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
|
||||
- PORT=${PORT:-3000}
|
||||
|
||||
# Mount source code for development (optional)
|
||||
|
||||
@@ -2,10 +2,9 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
mice:
|
||||
container_name: mice-public
|
||||
image: sillyangel/mice:dev-latest
|
||||
image: sillyangel/mice:latest
|
||||
ports:
|
||||
- "40625:40625"
|
||||
- "${HOST_PORT:-3000}:${PORT:-3000}"
|
||||
environment:
|
||||
# Navidrome Server Configuration
|
||||
# These will be injected at runtime using the entrypoint script
|
||||
@@ -13,32 +12,19 @@ services:
|
||||
- NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-}
|
||||
- NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-}
|
||||
|
||||
# PostHog Analytics (optional)
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
|
||||
|
||||
# Application Port
|
||||
- PORT=40625
|
||||
- PORT=${PORT:-3000}
|
||||
|
||||
# Optional: Add a health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:40625"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${PORT:-3000}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
navidrome:
|
||||
container_name: navidrome
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANINTERVAL=1m
|
||||
- ND_LOGLEVEL=info
|
||||
- ND_SESSIONTIMEOUT=24h
|
||||
- ND_PORT=4533
|
||||
# - ND_BASEURL=/navidrome
|
||||
# - ND_MUSICFOLDER=/music
|
||||
volumes:
|
||||
- navidrome_data:/data
|
||||
- navidrome_music:/music
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -15,3 +15,6 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
|
||||
done
|
||||
|
||||
echo "✅ Environment variable replacement complete"
|
||||
|
||||
# Execute the container's main process (CMD in Dockerfile)
|
||||
exec "$@"
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function useFavoriteAlbums() {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 300) : undefined
|
||||
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined
|
||||
};
|
||||
addFavoriteAlbum(favoriteAlbum);
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
'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
|
||||
};
|
||||
}
|
||||
110
hooks/use-library-cache.ts
Normal file
110
hooks/use-library-cache.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LibraryCacheItem<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function useLibraryCache<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl: number = 30 * 60 * 1000 // 30 minutes default
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const getCacheKey = (key: string) => `library-cache-${key}`;
|
||||
|
||||
const getFromCache = (key: string): T | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const cached = localStorage.getItem(getCacheKey(key));
|
||||
if (!cached) return null;
|
||||
|
||||
const item: LibraryCacheItem<T> = JSON.parse(cached);
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > item.expiresAt) {
|
||||
localStorage.removeItem(getCacheKey(key));
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.data;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get cached data:', error);
|
||||
localStorage.removeItem(getCacheKey(key));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setToCache = (key: string, data: T, ttl: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const item: LibraryCacheItem<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + ttl
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(getCacheKey(key), JSON.stringify(item));
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const cached = getFromCache(key);
|
||||
if (cached) {
|
||||
setData(cached);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
setToCache(key, result, ttl);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [key, ttl]);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
setToCache(key, result, ttl);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(getCacheKey(key));
|
||||
};
|
||||
|
||||
return { data, loading, error, refresh, clearCache };
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Track } from '@/app/components/AudioPlayerContext';
|
||||
|
||||
// Interface for a single day's listening data
|
||||
export interface DayStreakData {
|
||||
date: string; // ISO string of the date
|
||||
tracks: number; // Number of tracks played that day
|
||||
uniqueArtists: Set<string>; // Unique artists listened to
|
||||
uniqueAlbums: Set<string>; // Unique albums listened to
|
||||
totalListeningTime: number; // Total seconds listened
|
||||
}
|
||||
|
||||
// Interface for streak statistics
|
||||
export interface StreakStats {
|
||||
currentStreak: number; // Current consecutive days streak
|
||||
longestStreak: number; // Longest streak ever achieved
|
||||
totalDaysListened: number; // Total days with listening activity
|
||||
lastListenedDate: string | null; // Last date with listening activity
|
||||
}
|
||||
|
||||
const STREAK_THRESHOLD_TRACKS = 3; // Minimum tracks to count as an active day
|
||||
const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes minimum listening time
|
||||
|
||||
export function useListeningStreak() {
|
||||
const [streakData, setStreakData] = useState<Map<string, DayStreakData>>(new Map());
|
||||
const [stats, setStats] = useState<StreakStats>({
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
totalDaysListened: 0,
|
||||
lastListenedDate: null,
|
||||
});
|
||||
const { playedTracks, currentTrack } = useAudioPlayer();
|
||||
|
||||
// Initialize streak data from localStorage
|
||||
useEffect(() => {
|
||||
// Check if we're in the browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const savedStreakData = localStorage.getItem('navidrome-streak-data');
|
||||
const savedStats = localStorage.getItem('navidrome-streak-stats');
|
||||
|
||||
if (savedStreakData) {
|
||||
// Convert the plain object back to a Map
|
||||
const parsedData = JSON.parse(savedStreakData);
|
||||
const dataMap = new Map<string, DayStreakData>();
|
||||
|
||||
// Reconstruct the Map and Sets
|
||||
Object.entries(parsedData).forEach(([key, value]: [string, any]) => {
|
||||
dataMap.set(key, {
|
||||
...value,
|
||||
uniqueArtists: new Set(value.uniqueArtists),
|
||||
uniqueAlbums: new Set(value.uniqueAlbums)
|
||||
});
|
||||
});
|
||||
|
||||
setStreakData(dataMap);
|
||||
}
|
||||
|
||||
if (savedStats) {
|
||||
setStats(JSON.parse(savedStats));
|
||||
}
|
||||
|
||||
// Check if we need to update the streak based on the current date
|
||||
updateStreakStatus();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load streak data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save streak data to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || streakData.size === 0) return;
|
||||
|
||||
try {
|
||||
// Convert Map to a plain object for serialization
|
||||
const dataObject: Record<string, any> = {};
|
||||
|
||||
streakData.forEach((value, key) => {
|
||||
dataObject[key] = {
|
||||
...value,
|
||||
uniqueArtists: Array.from(value.uniqueArtists),
|
||||
uniqueAlbums: Array.from(value.uniqueAlbums)
|
||||
};
|
||||
});
|
||||
|
||||
localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject));
|
||||
localStorage.setItem('navidrome-streak-stats', JSON.stringify(stats));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save streak data:', error);
|
||||
}
|
||||
}, [streakData, stats]);
|
||||
|
||||
// Process playedTracks to update the streak
|
||||
useEffect(() => {
|
||||
if (playedTracks.length === 0) return;
|
||||
|
||||
// Get today's date in YYYY-MM-DD format
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Update streak data for today
|
||||
setStreakData(prev => {
|
||||
const updated = new Map(prev);
|
||||
|
||||
const todayData = updated.get(today) || {
|
||||
date: today,
|
||||
tracks: 0,
|
||||
uniqueArtists: new Set<string>(),
|
||||
uniqueAlbums: new Set<string>(),
|
||||
totalListeningTime: 0
|
||||
};
|
||||
|
||||
// Update today's data based on played tracks
|
||||
// For simplicity, we'll assume one track added = one complete listen
|
||||
const lastTrack = playedTracks[playedTracks.length - 1];
|
||||
|
||||
todayData.tracks += 1;
|
||||
todayData.uniqueArtists.add(lastTrack.artistId);
|
||||
todayData.uniqueAlbums.add(lastTrack.albumId);
|
||||
todayData.totalListeningTime += lastTrack.duration;
|
||||
|
||||
updated.set(today, todayData);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Update streak statistics
|
||||
updateStreakStatus();
|
||||
}, [playedTracks.length]);
|
||||
|
||||
// Function to update streak status based on current data
|
||||
const updateStreakStatus = useCallback(() => {
|
||||
if (streakData.size === 0) return;
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||||
|
||||
// Sort dates in descending order (newest first)
|
||||
const dates = Array.from(streakData.keys()).sort((a, b) =>
|
||||
new Date(b).getTime() - new Date(a).getTime()
|
||||
);
|
||||
|
||||
// Check which days count as active based on threshold
|
||||
const activeDays = dates.filter(date => {
|
||||
const dayData = streakData.get(date);
|
||||
if (!dayData) return false;
|
||||
|
||||
return dayData.tracks >= STREAK_THRESHOLD_TRACKS ||
|
||||
dayData.totalListeningTime >= STREAK_THRESHOLD_TIME;
|
||||
});
|
||||
|
||||
// Calculate current streak
|
||||
let currentStreak = 0;
|
||||
let checkDate = new Date(today);
|
||||
|
||||
// Keep checking consecutive days backward until streak breaks
|
||||
while (true) {
|
||||
const dateString = checkDate.toISOString().split('T')[0];
|
||||
if (activeDays.includes(dateString)) {
|
||||
currentStreak++;
|
||||
checkDate.setDate(checkDate.getDate() - 1); // Go back one day
|
||||
} else {
|
||||
break; // Streak broken
|
||||
}
|
||||
}
|
||||
|
||||
// Get total active days
|
||||
const totalDaysListened = activeDays.length;
|
||||
|
||||
// Get longest streak (requires analyzing all streaks)
|
||||
let longestStreak = currentStreak;
|
||||
let tempStreak = 0;
|
||||
|
||||
// Sort dates in ascending order for streak calculation
|
||||
const ascDates = [...activeDays].sort();
|
||||
|
||||
for (let i = 0; i < ascDates.length; i++) {
|
||||
const currentDate = new Date(ascDates[i]);
|
||||
|
||||
if (i > 0) {
|
||||
const prevDate = new Date(ascDates[i-1]);
|
||||
prevDate.setDate(prevDate.getDate() + 1);
|
||||
|
||||
// If dates are consecutive
|
||||
if (currentDate.getTime() === prevDate.getTime()) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
// Streak broken
|
||||
tempStreak = 1;
|
||||
}
|
||||
} else {
|
||||
tempStreak = 1; // First active day
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
}
|
||||
|
||||
// Get last listened date
|
||||
const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null;
|
||||
|
||||
// Update stats
|
||||
setStats({
|
||||
currentStreak,
|
||||
longestStreak,
|
||||
totalDaysListened,
|
||||
lastListenedDate
|
||||
});
|
||||
}, [streakData]);
|
||||
|
||||
// Check if user has listened today
|
||||
const hasListenedToday = useCallback(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayData = streakData.get(today);
|
||||
|
||||
return todayData && (
|
||||
todayData.tracks >= STREAK_THRESHOLD_TRACKS ||
|
||||
todayData.totalListeningTime >= STREAK_THRESHOLD_TIME
|
||||
);
|
||||
}, [streakData]);
|
||||
|
||||
// Get streak emoji representation
|
||||
const getStreakEmoji = useCallback(() => {
|
||||
if (stats.currentStreak <= 0) return '';
|
||||
|
||||
if (stats.currentStreak >= 30) return '🔥🔥🔥'; // 30+ days
|
||||
if (stats.currentStreak >= 14) return '🔥🔥'; // 14+ days
|
||||
if (stats.currentStreak >= 7) return '🔥'; // 7+ days
|
||||
if (stats.currentStreak >= 3) return '✨'; // 3+ days
|
||||
return '📅'; // 1-2 days
|
||||
}, [stats.currentStreak]);
|
||||
|
||||
// Get today's listening summary
|
||||
const getTodaySummary = useCallback(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayData = streakData.get(today);
|
||||
|
||||
if (!todayData) {
|
||||
return {
|
||||
tracks: 0,
|
||||
artists: 0,
|
||||
albums: 0,
|
||||
time: '0m'
|
||||
};
|
||||
}
|
||||
|
||||
// Format time nicely
|
||||
const minutes = Math.floor(todayData.totalListeningTime / 60);
|
||||
const timeDisplay = minutes === 1 ? '1m' : `${minutes}m`;
|
||||
|
||||
return {
|
||||
tracks: todayData.tracks,
|
||||
artists: todayData.uniqueArtists.size,
|
||||
albums: todayData.uniqueAlbums.size,
|
||||
time: timeDisplay
|
||||
};
|
||||
}, [streakData]);
|
||||
|
||||
// Reset streak data (for testing)
|
||||
const resetStreakData = useCallback(() => {
|
||||
setStreakData(new Map());
|
||||
setStats({
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
totalDaysListened: 0,
|
||||
lastListenedDate: null,
|
||||
});
|
||||
|
||||
localStorage.removeItem('navidrome-streak-data');
|
||||
localStorage.removeItem('navidrome-streak-stats');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
stats,
|
||||
hasListenedToday,
|
||||
getStreakEmoji,
|
||||
getTodaySummary,
|
||||
resetStreakData,
|
||||
streakThresholds: {
|
||||
tracks: STREAK_THRESHOLD_TRACKS,
|
||||
time: STREAK_THRESHOLD_TIME
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface OfflineTrack extends Track {
|
||||
isOffline?: boolean;
|
||||
offlineUrl?: string;
|
||||
}
|
||||
|
||||
export function useOfflineAudioPlayer() {
|
||||
const {
|
||||
playTrack,
|
||||
addToQueue,
|
||||
currentTrack,
|
||||
...audioPlayerProps
|
||||
} = useAudioPlayer();
|
||||
|
||||
const { isSupported: isOfflineSupported, checkOfflineStatus } = useOfflineDownloads();
|
||||
const { isOnline, scrobbleOffline } = useOfflineLibrary();
|
||||
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
// Convert song to track with offline awareness
|
||||
const songToTrack = useCallback(async (song: Song): Promise<OfflineTrack> => {
|
||||
let track: OfflineTrack = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
artist: song.artist,
|
||||
album: song.album || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
// Check if song is available offline
|
||||
if (isOfflineSupported) {
|
||||
const offlineStatus = await checkOfflineStatus(song.id, 'song');
|
||||
if (offlineStatus) {
|
||||
track.isOffline = true;
|
||||
track.offlineUrl = `offline-song-${song.id}`;
|
||||
// Prefer offline cached URL to avoid re-streaming even when online
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return track;
|
||||
}, [api, isOfflineSupported, checkOfflineStatus]);
|
||||
|
||||
// Play track with offline fallback
|
||||
const playTrackOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
// Already a track
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
// Convert song to track
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
playTrack(track);
|
||||
|
||||
// Scrobble with offline support
|
||||
scrobbleOffline(track.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play track:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, scrobbleOffline, isOnline]);
|
||||
|
||||
// Play album with offline awareness
|
||||
const playAlbumOffline = useCallback(async (album: Album, songs: Song[], startIndex: number = 0) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks with offline awareness
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter to only available tracks (online or offline)
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true; // All tracks available when online
|
||||
return track.isOffline; // Only offline tracks when offline
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for playback');
|
||||
}
|
||||
|
||||
// Adjust start index if needed
|
||||
const safeStartIndex = Math.min(startIndex, availableTracks.length - 1);
|
||||
|
||||
// Play first track
|
||||
playTrack(availableTracks[safeStartIndex]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
const remainingTracks = [
|
||||
...availableTracks.slice(safeStartIndex + 1),
|
||||
...availableTracks.slice(0, safeStartIndex)
|
||||
];
|
||||
|
||||
remainingTracks.forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(availableTracks[safeStartIndex].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play album offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Add track to queue with offline awareness
|
||||
const addToQueueOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// Check if track is available
|
||||
if (!isOnline && !track.isOffline) {
|
||||
throw new Error('Track not available offline');
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
addToQueue(track);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to add track to queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, addToQueue, isOnline]);
|
||||
|
||||
// Shuffle play with offline awareness
|
||||
const shufflePlayOffline = useCallback(async (songs: Song[]) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter available tracks
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true;
|
||||
return track.isOffline;
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for shuffle play');
|
||||
}
|
||||
|
||||
// Shuffle the available tracks
|
||||
const shuffledTracks = [...availableTracks].sort(() => Math.random() - 0.5);
|
||||
|
||||
// Play first track
|
||||
playTrack(shuffledTracks[0]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
shuffledTracks.slice(1).forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(shuffledTracks[0].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle play offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Get availability info for a song
|
||||
const getTrackAvailability = useCallback(async (song: Song): Promise<{
|
||||
isAvailable: boolean;
|
||||
isOffline: boolean;
|
||||
requiresConnection: boolean;
|
||||
}> => {
|
||||
try {
|
||||
const track = await songToTrack(song);
|
||||
|
||||
return {
|
||||
isAvailable: isOnline || !!track.isOffline,
|
||||
isOffline: !!track.isOffline,
|
||||
requiresConnection: !track.isOffline
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check track availability:', error);
|
||||
return {
|
||||
isAvailable: false,
|
||||
isOffline: false,
|
||||
requiresConnection: true
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Get album availability info
|
||||
const getAlbumAvailability = useCallback(async (songs: Song[]): Promise<{
|
||||
totalTracks: number;
|
||||
availableTracks: number;
|
||||
offlineTracks: number;
|
||||
onlineOnlyTracks: number;
|
||||
}> => {
|
||||
try {
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
const offlineTracks = tracks.filter((t: OfflineTrack) => t.isOffline).length;
|
||||
const onlineOnlyTracks = tracks.filter((t: OfflineTrack) => !t.isOffline).length;
|
||||
const availableTracks = isOnline ? tracks.length : offlineTracks;
|
||||
|
||||
return {
|
||||
totalTracks: tracks.length,
|
||||
availableTracks,
|
||||
offlineTracks,
|
||||
onlineOnlyTracks
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check album availability:', error);
|
||||
return {
|
||||
totalTracks: songs.length,
|
||||
availableTracks: 0,
|
||||
offlineTracks: 0,
|
||||
onlineOnlyTracks: songs.length
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Enhanced track info with offline status
|
||||
const getCurrentTrackInfo = useCallback(() => {
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const offlineTrack = currentTrack as OfflineTrack;
|
||||
|
||||
return {
|
||||
...currentTrack,
|
||||
isAvailableOffline: offlineTrack.isOffline || false,
|
||||
isPlayingOffline: !isOnline && !!offlineTrack.isOffline
|
||||
};
|
||||
}, [currentTrack, isOnline]);
|
||||
|
||||
return {
|
||||
// Original audio player props
|
||||
...audioPlayerProps,
|
||||
currentTrack,
|
||||
|
||||
// Enhanced offline methods
|
||||
playTrackOffline,
|
||||
playAlbumOffline,
|
||||
addToQueueOffline,
|
||||
shufflePlayOffline,
|
||||
|
||||
// Utility methods
|
||||
songToTrack,
|
||||
getTrackAvailability,
|
||||
getAlbumAvailability,
|
||||
getCurrentTrackInfo,
|
||||
|
||||
// State
|
||||
isOnline,
|
||||
isOfflineSupported
|
||||
};
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface DownloadProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error' | 'paused';
|
||||
currentSong?: string;
|
||||
currentArtist?: string;
|
||||
currentAlbum?: string;
|
||||
error?: string;
|
||||
downloadSpeed?: number; // In bytes per second
|
||||
timeRemaining?: number; // In seconds
|
||||
percentComplete?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface OfflineItem {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
name: string;
|
||||
artist: string;
|
||||
downloadedAt: number;
|
||||
size?: number;
|
||||
bitRate?: number;
|
||||
duration?: number;
|
||||
format?: string;
|
||||
lastPlayed?: number;
|
||||
}
|
||||
|
||||
export interface OfflineStats {
|
||||
totalSize: number;
|
||||
audioSize: number;
|
||||
imageSize: number;
|
||||
metaSize: number;
|
||||
downloadedAlbums: number;
|
||||
downloadedSongs: number;
|
||||
lastDownload: number | null;
|
||||
downloadErrors: number;
|
||||
remainingStorage: number | null;
|
||||
autoDownloadEnabled: boolean;
|
||||
downloadQuality: 'original' | 'high' | 'medium' | 'low';
|
||||
downloadOnWifiOnly: boolean;
|
||||
priorityContent: string[]; // IDs of albums or playlists that should always be available offline
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
private worker: ServiceWorker | null = null;
|
||||
private messageChannel: MessageChannel | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
console.log('Service Worker registered:', registration);
|
||||
|
||||
// Wait for the service worker to be ready
|
||||
const readyRegistration = await navigator.serviceWorker.ready;
|
||||
this.worker = readyRegistration.active;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async sendMessage(type: string, data: any): Promise<any> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type: responseType, data: responseData } = event.data;
|
||||
if (responseType.includes('ERROR')) {
|
||||
reject(new Error(responseData.error));
|
||||
} else {
|
||||
resolve(responseData);
|
||||
}
|
||||
};
|
||||
|
||||
this.worker!.postMessage({ type, data }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAlbum(
|
||||
album: Album,
|
||||
songs: Song[],
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_PROGRESS':
|
||||
if (onProgress) {
|
||||
onProgress(data);
|
||||
}
|
||||
break;
|
||||
case 'DOWNLOAD_COMPLETE':
|
||||
resolve();
|
||||
break;
|
||||
case 'DOWNLOAD_ERROR':
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility)
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_ALBUM',
|
||||
data: { album, songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSong(
|
||||
song: Song,
|
||||
options?: { quality?: 'original' | 'high' | 'medium' | 'low', priority?: boolean }
|
||||
): Promise<void> {
|
||||
const songWithUrl = {
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id, options?.quality),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
priority: options?.priority || false,
|
||||
quality: options?.quality || 'original'
|
||||
};
|
||||
|
||||
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
|
||||
}
|
||||
|
||||
async downloadQueue(
|
||||
songs: Song[],
|
||||
options?: {
|
||||
quality?: 'original' | 'high' | 'medium' | 'low',
|
||||
priority?: boolean,
|
||||
onProgressUpdate?: (progress: DownloadProgress) => void
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_PROGRESS':
|
||||
if (options?.onProgressUpdate) {
|
||||
options.onProgressUpdate(data);
|
||||
}
|
||||
break;
|
||||
case 'DOWNLOAD_COMPLETE':
|
||||
resolve();
|
||||
break;
|
||||
case 'DOWNLOAD_ERROR':
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id, options?.quality),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
priority: options?.priority || false,
|
||||
quality: options?.quality || 'original'
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_QUEUE',
|
||||
data: { songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async pauseDownloads(): Promise<void> {
|
||||
return this.sendMessage('PAUSE_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async resumeDownloads(): Promise<void> {
|
||||
return this.sendMessage('RESUME_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async cancelDownloads(): Promise<void> {
|
||||
return this.sendMessage('CANCEL_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async setDownloadPreferences(preferences: {
|
||||
quality: 'original' | 'high' | 'medium' | 'low',
|
||||
wifiOnly: boolean,
|
||||
autoDownloadRecent: boolean,
|
||||
autoDownloadFavorites: boolean,
|
||||
maxStoragePercent: number,
|
||||
priorityContent?: string[] // IDs of albums or playlists
|
||||
}): Promise<void> {
|
||||
return this.sendMessage('SET_DOWNLOAD_PREFERENCES', preferences);
|
||||
}
|
||||
|
||||
async getDownloadPreferences(): Promise<{
|
||||
quality: 'original' | 'high' | 'medium' | 'low',
|
||||
wifiOnly: boolean,
|
||||
autoDownloadRecent: boolean,
|
||||
autoDownloadFavorites: boolean,
|
||||
maxStoragePercent: number,
|
||||
priorityContent: string[]
|
||||
}> {
|
||||
return this.sendMessage('GET_DOWNLOAD_PREFERENCES', {});
|
||||
}
|
||||
|
||||
async enableOfflineMode(settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}): Promise<void> {
|
||||
return this.sendMessage('ENABLE_OFFLINE_MODE', settings);
|
||||
}
|
||||
|
||||
async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.sendMessage('CHECK_OFFLINE_STATUS', { id, type });
|
||||
return result.isAvailable;
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContent(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type });
|
||||
}
|
||||
|
||||
async getOfflineStats(): Promise<OfflineStats> {
|
||||
return this.sendMessage('GET_OFFLINE_STATS', {});
|
||||
}
|
||||
|
||||
async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> {
|
||||
return this.sendMessage('GET_OFFLINE_ITEMS', {});
|
||||
}
|
||||
|
||||
private getDownloadUrl(songId: string, quality?: 'original' | 'high' | 'medium' | 'low'): string {
|
||||
const api = getNavidromeAPI();
|
||||
if (!api) throw new Error('Navidrome server not configured');
|
||||
|
||||
// Use direct download to fetch original file by default
|
||||
if (quality === 'original' || !quality) {
|
||||
if (typeof (api as any).getDownloadUrl === 'function') {
|
||||
return (api as any).getDownloadUrl(songId);
|
||||
}
|
||||
}
|
||||
|
||||
// For other quality settings, use the stream URL with appropriate parameters
|
||||
const maxBitRate = quality === 'high' ? 320 :
|
||||
quality === 'medium' ? 192 :
|
||||
quality === 'low' ? 128 : undefined;
|
||||
|
||||
// Note: format parameter is not supported by the Navidrome API
|
||||
// The server will automatically transcode based on maxBitRate
|
||||
return api.getStreamUrl(songId, maxBitRate);
|
||||
}
|
||||
|
||||
// LocalStorage fallback for browsers without service worker support
|
||||
async downloadAlbumFallback(album: Album, songs: Song[]): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
// Store album metadata
|
||||
offlineData.albums[album.id] = {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: Date.now(),
|
||||
songCount: songs.length,
|
||||
songs: songs.map(song => song.id)
|
||||
};
|
||||
|
||||
// Mark songs as downloaded (metadata only in localStorage fallback)
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
public getOfflineData() {
|
||||
const stored = localStorage.getItem('offline-downloads');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse offline data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
albums: {},
|
||||
songs: {},
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
public saveOfflineData(data: any) {
|
||||
data.lastUpdated = Date.now();
|
||||
localStorage.setItem('offline-downloads', JSON.stringify(data));
|
||||
}
|
||||
|
||||
async checkOfflineStatusFallback(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
return !!offlineData.albums[id];
|
||||
} else {
|
||||
return !!offlineData.songs[id];
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
const album = offlineData.albums[id];
|
||||
if (album && album.songs) {
|
||||
// Remove associated songs
|
||||
album.songs.forEach((songId: string) => {
|
||||
delete offlineData.songs[songId];
|
||||
});
|
||||
}
|
||||
delete offlineData.albums[id];
|
||||
} else {
|
||||
delete offlineData.songs[id];
|
||||
}
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
getOfflineAlbums(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.albums).map((album: any) => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: album.downloadedAt
|
||||
}));
|
||||
}
|
||||
|
||||
getOfflineSongs(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.songs).map((song: any) => ({
|
||||
id: song.id,
|
||||
type: 'song' as const,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
downloadedAt: song.downloadedAt
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance that will be initialized on the client side
|
||||
let downloadManagerInstance: DownloadManager | null = null;
|
||||
|
||||
// Only create the download manager instance on the client side
|
||||
if (typeof window !== 'undefined') {
|
||||
downloadManagerInstance = new DownloadManager();
|
||||
}
|
||||
|
||||
// Create a safe wrapper around the download manager
|
||||
const downloadManager = {
|
||||
initialize: async () => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.initialize();
|
||||
},
|
||||
getOfflineStats: async () => {
|
||||
if (!downloadManagerInstance) return {
|
||||
totalSize: 0,
|
||||
audioSize: 0,
|
||||
imageSize: 0,
|
||||
metaSize: 0,
|
||||
downloadedAlbums: 0,
|
||||
downloadedSongs: 0,
|
||||
lastDownload: null,
|
||||
downloadErrors: 0,
|
||||
remainingStorage: null,
|
||||
autoDownloadEnabled: false,
|
||||
downloadQuality: 'original' as const,
|
||||
downloadOnWifiOnly: true,
|
||||
priorityContent: []
|
||||
};
|
||||
return downloadManagerInstance.getOfflineStats();
|
||||
},
|
||||
downloadAlbum: async (album: Album, songs: Song[], progressCallback: (progress: DownloadProgress) => void) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadAlbum(album, songs, progressCallback);
|
||||
},
|
||||
downloadAlbumFallback: async (album: Album, songs: Song[]) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadAlbumFallback(album, songs);
|
||||
},
|
||||
downloadSong: async (song: Song) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadSong(song);
|
||||
},
|
||||
getOfflineData: () => {
|
||||
if (!downloadManagerInstance) return { albums: {}, songs: {} };
|
||||
return downloadManagerInstance.getOfflineData();
|
||||
},
|
||||
saveOfflineData: (data: any) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.saveOfflineData(data);
|
||||
},
|
||||
checkOfflineStatus: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.checkOfflineStatus(id, type);
|
||||
},
|
||||
checkOfflineStatusFallback: (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.checkOfflineStatusFallback(id, type);
|
||||
},
|
||||
deleteOfflineContent: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.deleteOfflineContent(id, type);
|
||||
},
|
||||
deleteOfflineContentFallback: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.deleteOfflineContentFallback(id, type);
|
||||
},
|
||||
getOfflineItems: async () => {
|
||||
if (!downloadManagerInstance) return { albums: [], songs: [] };
|
||||
return downloadManagerInstance.getOfflineItems();
|
||||
},
|
||||
getOfflineAlbums: () => {
|
||||
if (!downloadManagerInstance) return [];
|
||||
return downloadManagerInstance.getOfflineAlbums();
|
||||
},
|
||||
getOfflineSongs: () => {
|
||||
if (!downloadManagerInstance) return [];
|
||||
return downloadManagerInstance.getOfflineSongs();
|
||||
},
|
||||
downloadQueue: async (songs: Song[]) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadQueue(songs);
|
||||
},
|
||||
enableOfflineMode: async (settings: any) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.enableOfflineMode(settings);
|
||||
}
|
||||
};
|
||||
|
||||
export function useOfflineDownloads() {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
|
||||
const [offlineStats, setOfflineStats] = useState<OfflineStats>({
|
||||
totalSize: 0,
|
||||
audioSize: 0,
|
||||
imageSize: 0,
|
||||
metaSize: 0,
|
||||
downloadedAlbums: 0,
|
||||
downloadedSongs: 0,
|
||||
lastDownload: null,
|
||||
downloadErrors: 0,
|
||||
remainingStorage: null,
|
||||
autoDownloadEnabled: false,
|
||||
downloadQuality: 'original',
|
||||
downloadOnWifiOnly: true,
|
||||
priorityContent: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDownloadManager = async () => {
|
||||
// Skip initialization on server-side
|
||||
if (!downloadManager) {
|
||||
setIsSupported(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const supported = await downloadManager.initialize();
|
||||
setIsSupported(supported);
|
||||
setIsInitialized(true);
|
||||
|
||||
if (supported) {
|
||||
// Load initial stats
|
||||
try {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Failed to load offline stats:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeDownloadManager();
|
||||
}, []);
|
||||
|
||||
const downloadAlbum = useCallback(async (album: Album, songs: Song[]) => {
|
||||
try {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadAlbum(album, songs, setDownloadProgress);
|
||||
} else {
|
||||
// Fallback to localStorage metadata only
|
||||
await downloadManager.downloadAlbumFallback(album, songs);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error', error: (error as Error).message }));
|
||||
throw error;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const downloadSong = useCallback(async (song: Song) => {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadSong(song);
|
||||
} else {
|
||||
// Fallback - just save metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const checkOfflineStatus = useCallback(async (id: string, type: 'album' | 'song'): Promise<boolean> => {
|
||||
if (isSupported) {
|
||||
return downloadManager.checkOfflineStatus(id, type);
|
||||
} else {
|
||||
return downloadManager.checkOfflineStatusFallback(id, type);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const deleteOfflineContent = useCallback(async (id: string, type: 'album' | 'song') => {
|
||||
if (isSupported) {
|
||||
await downloadManager.deleteOfflineContent(id, type);
|
||||
} else {
|
||||
await downloadManager.deleteOfflineContentFallback(id, type);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const getOfflineItems = useCallback(async (): Promise<OfflineItem[]> => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
const { albums, songs } = await downloadManager.getOfflineItems();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
} catch (e) {
|
||||
console.error('Failed to get offline items from SW, falling back:', e);
|
||||
}
|
||||
}
|
||||
const albums = downloadManager.getOfflineAlbums();
|
||||
const songs = downloadManager.getOfflineSongs();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
}, [isSupported]);
|
||||
|
||||
const clearDownloadProgress = useCallback(() => {
|
||||
setDownloadProgress({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
}, []);
|
||||
|
||||
const downloadQueue = useCallback(async (songs: Song[]) => {
|
||||
if (isSupported) {
|
||||
setDownloadProgress({ completed: 0, total: songs.length, failed: 0, status: 'downloading' });
|
||||
|
||||
try {
|
||||
await downloadManager.downloadQueue(songs);
|
||||
// Stats will be updated via progress events
|
||||
} catch (error) {
|
||||
console.error('Queue download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error' }));
|
||||
}
|
||||
} else {
|
||||
// Fallback: just store metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const enableOfflineMode = useCallback(async (settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}) => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
await downloadManager.enableOfflineMode(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to enable offline mode:', error);
|
||||
}
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isInitialized,
|
||||
downloadProgress,
|
||||
offlineStats,
|
||||
downloadAlbum,
|
||||
downloadSong,
|
||||
downloadQueue,
|
||||
enableOfflineMode,
|
||||
checkOfflineStatus,
|
||||
deleteOfflineContent,
|
||||
getOfflineItems,
|
||||
clearDownloadProgress
|
||||
};
|
||||
}
|
||||
|
||||
// Export the manager instance for direct use if needed
|
||||
export { downloadManager };
|
||||
@@ -1,517 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { offlineLibraryDB, LibrarySyncStats, OfflineAlbum, OfflineArtist, OfflineSong, OfflinePlaylist } from '@/lib/indexeddb';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getNavidromeAPI, Song } from '@/lib/navidrome';
|
||||
|
||||
export interface LibrarySyncProgress {
|
||||
phase: 'idle' | 'albums' | 'artists' | 'songs' | 'playlists' | 'operations' | 'complete' | 'error';
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LibrarySyncOptions {
|
||||
includeAlbums: boolean;
|
||||
includeArtists: boolean;
|
||||
includeSongs: boolean;
|
||||
includePlaylists: boolean;
|
||||
syncStarred: boolean;
|
||||
maxSongs: number; // Limit to prevent overwhelming the database
|
||||
}
|
||||
|
||||
const defaultSyncOptions: LibrarySyncOptions = {
|
||||
includeAlbums: true,
|
||||
includeArtists: true,
|
||||
includeSongs: true,
|
||||
includePlaylists: true,
|
||||
syncStarred: true,
|
||||
maxSongs: 1000 // Default limit
|
||||
};
|
||||
|
||||
export function useOfflineLibrarySync() {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncProgress, setSyncProgress] = useState<LibrarySyncProgress>({
|
||||
phase: 'idle',
|
||||
current: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
const [stats, setStats] = useState<LibrarySyncStats>({
|
||||
albums: 0,
|
||||
artists: 0,
|
||||
songs: 0,
|
||||
playlists: 0,
|
||||
lastSync: null,
|
||||
pendingOperations: 0,
|
||||
storageSize: 0,
|
||||
syncInProgress: false
|
||||
});
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
|
||||
const [syncOptions, setSyncOptions] = useState<LibrarySyncOptions>(defaultSyncOptions);
|
||||
|
||||
const { config, isConnected } = useNavidromeConfig();
|
||||
const api = useMemo(() => getNavidromeAPI(config), [config]);
|
||||
const { toast } = useToast();
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initialize the offline library database
|
||||
useEffect(() => {
|
||||
const initializeDB = async () => {
|
||||
try {
|
||||
const initialized = await offlineLibraryDB.initialize();
|
||||
setIsInitialized(initialized);
|
||||
|
||||
if (initialized) {
|
||||
await refreshStats();
|
||||
loadSyncSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeDB();
|
||||
}, []);
|
||||
|
||||
// Monitor online status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Check if navigator is available (client-side only)
|
||||
if (typeof navigator !== 'undefined') {
|
||||
setIsOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-sync when coming back online
|
||||
useEffect(() => {
|
||||
if (isOnline && isConnected && autoSyncEnabled && !isSyncing) {
|
||||
const pendingOpsSync = async () => {
|
||||
try {
|
||||
await syncPendingOperations();
|
||||
} catch (error) {
|
||||
console.error('Auto-sync failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delay auto-sync to avoid immediate trigger
|
||||
syncTimeoutRef.current = setTimeout(pendingOpsSync, 2000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isOnline, isConnected, autoSyncEnabled, isSyncing]);
|
||||
|
||||
const loadSyncSettings = useCallback(async () => {
|
||||
try {
|
||||
const [autoSync, savedOptions] = await Promise.all([
|
||||
offlineLibraryDB.getMetadata<boolean>('autoSyncEnabled'),
|
||||
offlineLibraryDB.getMetadata<LibrarySyncOptions>('syncOptions')
|
||||
]);
|
||||
|
||||
if (typeof autoSync === 'boolean') setAutoSyncEnabled(autoSync);
|
||||
|
||||
if (savedOptions) {
|
||||
setSyncOptions({ ...defaultSyncOptions, ...savedOptions });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStats = useCallback(async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
try {
|
||||
const newStats = await offlineLibraryDB.getStats();
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
const updateSyncProgress = useCallback((phase: LibrarySyncProgress['phase'], current: number, total: number, message: string) => {
|
||||
setSyncProgress({ phase, current, total, message });
|
||||
}, []);
|
||||
|
||||
const syncLibraryFromServer = useCallback(async (options: Partial<LibrarySyncOptions> = {}) => {
|
||||
if (!api || !isConnected || !isInitialized) {
|
||||
throw new Error('Cannot sync: API not available or not connected');
|
||||
}
|
||||
|
||||
if (isSyncing) {
|
||||
throw new Error('Sync already in progress');
|
||||
}
|
||||
|
||||
const actualOptions = { ...syncOptions, ...options };
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
await offlineLibraryDB.setMetadata('syncInProgress', true);
|
||||
|
||||
updateSyncProgress('albums', 0, 0, 'Testing server connection...');
|
||||
|
||||
// Test connection first
|
||||
const connected = await api.ping();
|
||||
if (!connected) {
|
||||
throw new Error('No connection to Navidrome server');
|
||||
}
|
||||
|
||||
let totalItems = 0;
|
||||
let processedItems = 0;
|
||||
|
||||
// Sync albums
|
||||
if (actualOptions.includeAlbums) {
|
||||
updateSyncProgress('albums', 0, 0, 'Fetching albums from server...');
|
||||
|
||||
const albums = await api.getAlbums('alphabeticalByName', 5000);
|
||||
totalItems += albums.length;
|
||||
|
||||
updateSyncProgress('albums', 0, albums.length, `Storing ${albums.length} albums...`);
|
||||
|
||||
const mappedAlbums: OfflineAlbum[] = albums.map(album => ({
|
||||
...album,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeAlbums(mappedAlbums);
|
||||
processedItems += albums.length;
|
||||
|
||||
updateSyncProgress('albums', albums.length, albums.length, `Stored ${albums.length} albums`);
|
||||
}
|
||||
|
||||
// Sync artists
|
||||
if (actualOptions.includeArtists) {
|
||||
updateSyncProgress('artists', processedItems, totalItems, 'Fetching artists from server...');
|
||||
|
||||
const artists = await api.getArtists();
|
||||
totalItems += artists.length;
|
||||
|
||||
updateSyncProgress('artists', 0, artists.length, `Storing ${artists.length} artists...`);
|
||||
|
||||
const mappedArtists: OfflineArtist[] = artists.map(artist => ({
|
||||
...artist,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeArtists(mappedArtists);
|
||||
processedItems += artists.length;
|
||||
|
||||
updateSyncProgress('artists', artists.length, artists.length, `Stored ${artists.length} artists`);
|
||||
}
|
||||
|
||||
// Sync playlists
|
||||
if (actualOptions.includePlaylists) {
|
||||
updateSyncProgress('playlists', processedItems, totalItems, 'Fetching playlists from server...');
|
||||
|
||||
const playlists = await api.getPlaylists();
|
||||
totalItems += playlists.length;
|
||||
|
||||
updateSyncProgress('playlists', 0, playlists.length, `Storing ${playlists.length} playlists...`);
|
||||
|
||||
const mappedPlaylists: OfflinePlaylist[] = await Promise.all(
|
||||
playlists.map(async (playlist) => {
|
||||
try {
|
||||
const playlistDetails = await api.getPlaylist(playlist.id);
|
||||
return {
|
||||
...playlist,
|
||||
songIds: (playlistDetails.songs || []).map((song: Song) => song.id),
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get details for playlist ${playlist.id}:`, error);
|
||||
return {
|
||||
...playlist,
|
||||
songIds: [],
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await offlineLibraryDB.storePlaylists(mappedPlaylists);
|
||||
processedItems += playlists.length;
|
||||
|
||||
updateSyncProgress('playlists', playlists.length, playlists.length, `Stored ${playlists.length} playlists`);
|
||||
}
|
||||
|
||||
// Sync songs (limited to avoid overwhelming the database)
|
||||
if (actualOptions.includeSongs) {
|
||||
updateSyncProgress('songs', processedItems, totalItems, 'Fetching songs from server...');
|
||||
|
||||
const albums = await offlineLibraryDB.getAlbums();
|
||||
const albumsToSync = albums.slice(0, Math.floor(actualOptions.maxSongs / 10)); // Roughly 10 songs per album
|
||||
|
||||
let songCount = 0;
|
||||
updateSyncProgress('songs', 0, albumsToSync.length, `Processing songs for ${albumsToSync.length} albums...`);
|
||||
|
||||
for (let i = 0; i < albumsToSync.length; i++) {
|
||||
const album = albumsToSync[i];
|
||||
try {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
|
||||
if (songCount + songs.length > actualOptions.maxSongs) {
|
||||
const remaining = actualOptions.maxSongs - songCount;
|
||||
if (remaining > 0) {
|
||||
const limitedSongs = songs.slice(0, remaining);
|
||||
const mappedSongs: OfflineSong[] = limitedSongs.map(song => ({
|
||||
...song,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
await offlineLibraryDB.storeSongs(mappedSongs);
|
||||
songCount += limitedSongs.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const mappedSongs: OfflineSong[] = songs.map(song => ({
|
||||
...song,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeSongs(mappedSongs);
|
||||
songCount += songs.length;
|
||||
|
||||
updateSyncProgress('songs', i + 1, albumsToSync.length, `Processed ${i + 1}/${albumsToSync.length} albums (${songCount} songs)`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync songs for album ${album.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
updateSyncProgress('songs', albumsToSync.length, albumsToSync.length, `Stored ${songCount} songs`);
|
||||
}
|
||||
|
||||
// Sync pending operations to server
|
||||
updateSyncProgress('operations', 0, 0, 'Syncing pending operations...');
|
||||
await syncPendingOperations();
|
||||
|
||||
// Update sync timestamp
|
||||
await offlineLibraryDB.setMetadata('lastSync', Date.now());
|
||||
|
||||
updateSyncProgress('complete', 100, 100, 'Library sync completed successfully');
|
||||
|
||||
toast({
|
||||
title: "Sync Complete",
|
||||
description: `Successfully synced library data offline`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Library sync failed:', error);
|
||||
updateSyncProgress('error', 0, 0, `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
await offlineLibraryDB.setMetadata('syncInProgress', false);
|
||||
await refreshStats();
|
||||
}
|
||||
}, [api, isConnected, isInitialized, isSyncing, syncOptions, toast, updateSyncProgress, refreshStats]);
|
||||
|
||||
const syncPendingOperations = useCallback(async () => {
|
||||
if (!api || !isConnected || !isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const operations = await offlineLibraryDB.getSyncOperations();
|
||||
|
||||
if (operations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSyncProgress('operations', 0, operations.length, 'Syncing pending operations...');
|
||||
|
||||
for (let i = 0; i < operations.length; i++) {
|
||||
const operation = operations[i];
|
||||
|
||||
try {
|
||||
switch (operation.type) {
|
||||
case 'star':
|
||||
if (operation.entityType !== 'playlist') {
|
||||
await api.star(operation.entityId, operation.entityType);
|
||||
}
|
||||
break;
|
||||
case 'unstar':
|
||||
if (operation.entityType !== 'playlist') {
|
||||
await api.unstar(operation.entityId, operation.entityType);
|
||||
}
|
||||
break;
|
||||
case 'scrobble':
|
||||
await api.scrobble(operation.entityId);
|
||||
break;
|
||||
case 'create_playlist':
|
||||
if ('name' in operation.data && typeof operation.data.name === 'string') {
|
||||
await api.createPlaylist(
|
||||
operation.data.name,
|
||||
'songIds' in operation.data ? operation.data.songIds : undefined
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'update_playlist':
|
||||
if ('name' in operation.data || 'comment' in operation.data || 'songIds' in operation.data) {
|
||||
const d = operation.data as { name?: string; comment?: string; songIds?: string[] };
|
||||
await api.updatePlaylist(operation.entityId, d.name, d.comment, d.songIds);
|
||||
}
|
||||
break;
|
||||
case 'delete_playlist':
|
||||
await api.deletePlaylist(operation.entityId);
|
||||
break;
|
||||
}
|
||||
|
||||
await offlineLibraryDB.removeSyncOperation(operation.id);
|
||||
updateSyncProgress('operations', i + 1, operations.length, `Synced ${i + 1}/${operations.length} operations`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync operation ${operation.id}:`, error);
|
||||
// Don't remove failed operations, they'll be retried later
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync pending operations:', error);
|
||||
}
|
||||
}, [api, isConnected, isInitialized, updateSyncProgress]);
|
||||
|
||||
const clearOfflineData = useCallback(async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.clearAllData();
|
||||
await refreshStats();
|
||||
|
||||
toast({
|
||||
title: "Offline Data Cleared",
|
||||
description: "All offline library data has been removed",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
toast({
|
||||
title: "Clear Failed",
|
||||
description: "Failed to clear offline data",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, [isInitialized, refreshStats, toast]);
|
||||
|
||||
const updateAutoSync = useCallback(async (enabled: boolean) => {
|
||||
setAutoSyncEnabled(enabled);
|
||||
try {
|
||||
await offlineLibraryDB.setMetadata('autoSyncEnabled', enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to save auto-sync setting:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSyncOptions = useCallback(async (newOptions: Partial<LibrarySyncOptions>) => {
|
||||
const updatedOptions = { ...syncOptions, ...newOptions };
|
||||
setSyncOptions(updatedOptions);
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.setMetadata('syncOptions', updatedOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to save sync options:', error);
|
||||
}
|
||||
}, [syncOptions]);
|
||||
|
||||
// Offline-first operations
|
||||
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
if (!isInitialized) throw new Error('Offline library not initialized');
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.starItem(id, type);
|
||||
await refreshStats();
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (isOnline && isConnected && api) {
|
||||
try {
|
||||
await api.star(id, type);
|
||||
await offlineLibraryDB.removeSyncOperation(`star-${id}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to sync star operation immediately, will retry later:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to star item:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
|
||||
|
||||
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
if (!isInitialized) throw new Error('Offline library not initialized');
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.unstarItem(id, type);
|
||||
await refreshStats();
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (isOnline && isConnected && api) {
|
||||
try {
|
||||
await api.unstar(id, type);
|
||||
await offlineLibraryDB.removeSyncOperation(`unstar-${id}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to sync unstar operation immediately, will retry later:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar item:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
isSyncing,
|
||||
syncProgress,
|
||||
stats,
|
||||
isOnline,
|
||||
autoSyncEnabled,
|
||||
syncOptions,
|
||||
|
||||
// Actions
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
clearOfflineData,
|
||||
updateAutoSync,
|
||||
updateSyncOptions,
|
||||
refreshStats,
|
||||
starItem,
|
||||
unstarItem,
|
||||
|
||||
// Data access (for offline access)
|
||||
getOfflineAlbums: () => offlineLibraryDB.getAlbums(),
|
||||
getOfflineArtists: () => offlineLibraryDB.getArtists(),
|
||||
getOfflineSongs: (albumId?: string) => offlineLibraryDB.getSongs(albumId),
|
||||
getOfflinePlaylists: () => offlineLibraryDB.getPlaylists(),
|
||||
getOfflineAlbum: (id: string) => offlineLibraryDB.getAlbum(id)
|
||||
};
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { offlineLibraryManager, type OfflineLibraryStats, type SyncOperation } from '@/lib/offline-library';
|
||||
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
|
||||
export interface OfflineLibraryState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSync: Date | null;
|
||||
stats: OfflineLibraryStats;
|
||||
syncProgress: {
|
||||
current: number;
|
||||
total: number;
|
||||
stage: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useOfflineLibrary() {
|
||||
// Check if we're on the client side
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
const [state, setState] = useState<OfflineLibraryState>({
|
||||
isInitialized: false,
|
||||
isOnline: isClient ? navigator.onLine : true, // Default to true during SSR
|
||||
isSyncing: false,
|
||||
lastSync: null,
|
||||
stats: {
|
||||
albums: 0,
|
||||
artists: 0,
|
||||
songs: 0,
|
||||
playlists: 0,
|
||||
lastSync: null,
|
||||
pendingOperations: 0,
|
||||
storageSize: 0
|
||||
},
|
||||
syncProgress: null
|
||||
});
|
||||
|
||||
const { api } = useNavidrome();
|
||||
|
||||
// Initialize offline library
|
||||
useEffect(() => {
|
||||
const initializeOfflineLibrary = async () => {
|
||||
try {
|
||||
const initialized = await offlineLibraryManager.initialize();
|
||||
if (initialized) {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeOfflineLibrary();
|
||||
}, []);
|
||||
|
||||
// Listen for online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: true }));
|
||||
// Automatically sync when back online
|
||||
if (state.isInitialized && api) {
|
||||
syncPendingOperations();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: false }));
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [state.isInitialized, api]);
|
||||
|
||||
// Full library sync from server
|
||||
const syncLibraryFromServer = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized || state.isSyncing) return;
|
||||
|
||||
try {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: true,
|
||||
syncProgress: { current: 0, total: 100, stage: 'Starting sync...' }
|
||||
}));
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 20, total: 100, stage: 'Syncing albums...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncFromServer(api);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 80, total: 100, stage: 'Syncing pending operations...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Library sync failed:', error);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}, [api, state.isInitialized, state.isSyncing]);
|
||||
|
||||
// Sync only pending operations
|
||||
const syncPendingOperations = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
} catch (error) {
|
||||
console.error('Failed to sync pending operations:', error);
|
||||
}
|
||||
}, [api, state.isInitialized]);
|
||||
|
||||
// Data retrieval methods (offline-first)
|
||||
const getAlbums = useCallback(async (starred?: boolean): Promise<Album[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineAlbums = await offlineLibraryManager.getAlbums(starred);
|
||||
|
||||
// If offline data exists, return it
|
||||
if (offlineAlbums.length > 0) {
|
||||
return offlineAlbums;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverAlbums = starred
|
||||
? await api.getAlbums('starred')
|
||||
: await api.getAlbums('alphabeticalByName', 100);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums(serverAlbums);
|
||||
return serverAlbums;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get albums:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getArtists = useCallback(async (starred?: boolean): Promise<Artist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineArtists = await offlineLibraryManager.getArtists(starred);
|
||||
|
||||
if (offlineArtists.length > 0) {
|
||||
return offlineArtists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverArtists = await api.getArtists();
|
||||
await offlineLibraryManager.storeArtists(serverArtists);
|
||||
return serverArtists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get artists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getAlbum = useCallback(async (albumId: string): Promise<{ album: Album; songs: Song[] } | null> => {
|
||||
if (!state.isInitialized) return null;
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineData = await offlineLibraryManager.getAlbum(albumId);
|
||||
|
||||
if (offlineData && offlineData.songs.length > 0) {
|
||||
return offlineData;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverData = await api.getAlbum(albumId);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums([serverData.album]);
|
||||
await offlineLibraryManager.storeSongs(serverData.songs);
|
||||
|
||||
return serverData;
|
||||
}
|
||||
|
||||
return offlineData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get album:', error);
|
||||
return null;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getPlaylists = useCallback(async (): Promise<Playlist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlinePlaylists = await offlineLibraryManager.getPlaylists();
|
||||
|
||||
if (offlinePlaylists.length > 0) {
|
||||
return offlinePlaylists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverPlaylists = await api.getPlaylists();
|
||||
await offlineLibraryManager.storePlaylists(serverPlaylists);
|
||||
return serverPlaylists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get playlists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Search (offline-first)
|
||||
const searchOffline = useCallback(async (query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> => {
|
||||
if (!state.isInitialized) {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const offlineResults = await offlineLibraryManager.searchOffline(query);
|
||||
|
||||
// If we have good offline results, return them
|
||||
const totalResults = offlineResults.artists.length + offlineResults.albums.length + offlineResults.songs.length;
|
||||
if (totalResults > 0) {
|
||||
return offlineResults;
|
||||
}
|
||||
|
||||
// If no offline results and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
return await api.search2(query);
|
||||
}
|
||||
|
||||
return offlineResults;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Offline favorites management
|
||||
const starOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
await api.star(id, type);
|
||||
}
|
||||
|
||||
// Always update offline data
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
|
||||
// Update stats
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to star item:', error);
|
||||
// If server failed but we're online, still save offline for later sync
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const unstarOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.unstar(id, type);
|
||||
}
|
||||
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar item:', error);
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Playlist management
|
||||
const createPlaylistOffline = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
|
||||
if (!state.isInitialized) {
|
||||
throw new Error('Offline library not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
const serverPlaylist = await api.createPlaylist(name, songIds);
|
||||
await offlineLibraryManager.storePlaylists([serverPlaylist]);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return serverPlaylist;
|
||||
} else {
|
||||
// If offline, create locally and queue for sync
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return offlinePlaylist;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create playlist:', error);
|
||||
|
||||
// If server failed but we're online, create offline version for later sync
|
||||
if (state.isOnline) {
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
return offlinePlaylist;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Scrobble (offline-capable)
|
||||
const scrobbleOffline = useCallback(async (songId: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.scrobble(songId);
|
||||
} else {
|
||||
// Queue for later sync
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble:', error);
|
||||
// Queue for later sync if server failed
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Clear all offline data
|
||||
const clearOfflineData = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.clearAllData();
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stats,
|
||||
lastSync: null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Get songs with offline-first approach
|
||||
const getSongs = useCallback(async (albumId?: string, artistId?: string): Promise<Song[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineSongs = await offlineLibraryManager.getSongs(albumId, artistId);
|
||||
|
||||
if (offlineSongs.length > 0) {
|
||||
return offlineSongs;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
let serverSongs: Song[] = [];
|
||||
|
||||
if (albumId) {
|
||||
const { songs } = await api.getAlbum(albumId);
|
||||
await offlineLibraryManager.storeSongs(songs);
|
||||
serverSongs = songs;
|
||||
} else if (artistId) {
|
||||
const { albums } = await api.getArtist(artistId);
|
||||
const allSongs: Song[] = [];
|
||||
for (const album of albums) {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
allSongs.push(...songs);
|
||||
}
|
||||
await offlineLibraryManager.storeSongs(allSongs);
|
||||
serverSongs = allSongs;
|
||||
}
|
||||
|
||||
return serverSongs;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get songs:', error);
|
||||
return [];
|
||||
}
|
||||
}, [api, state.isInitialized, state.isOnline]);
|
||||
|
||||
// Queue sync operation
|
||||
const queueSyncOperation = useCallback(async (operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
const fullOperation: SyncOperation = {
|
||||
...operation,
|
||||
id: `${operation.type}-${operation.entityId}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
await offlineLibraryManager.addSyncOperation(fullOperation);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Update playlist offline
|
||||
const updatePlaylistOffline = useCallback(async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.updatePlaylist(id, name, comment, songIds);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Delete playlist offline
|
||||
const deletePlaylistOffline = useCallback(async (id: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.deletePlaylist(id);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats, lastSync: stats.lastSync }));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Sync methods
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
|
||||
// Data retrieval (offline-first)
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSongs,
|
||||
getAlbum,
|
||||
getPlaylists,
|
||||
searchOffline,
|
||||
|
||||
// Offline operations
|
||||
starOffline,
|
||||
unstarOffline,
|
||||
createPlaylistOffline,
|
||||
updatePlaylistOffline,
|
||||
deletePlaylistOffline,
|
||||
scrobbleOffline,
|
||||
queueSyncOperation,
|
||||
|
||||
// Management
|
||||
clearOfflineData,
|
||||
refreshStats
|
||||
};
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
'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
|
||||
};
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseResponsiveImageSizeOptions {
|
||||
/** Minimum size threshold */
|
||||
minSize?: number;
|
||||
/** Maximum size threshold */
|
||||
maxSize?: number;
|
||||
/** Multiplier for high DPI displays */
|
||||
dpiMultiplier?: number;
|
||||
/** Available size tiers from Navidrome */
|
||||
availableSizes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate optimal image size based on container dimensions
|
||||
*/
|
||||
export function useResponsiveImageSize(options: UseResponsiveImageSizeOptions = {}) {
|
||||
const {
|
||||
minSize = 60,
|
||||
maxSize = 1200,
|
||||
dpiMultiplier = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1,
|
||||
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const [imageSize, setImageSize] = useState<number>(300); // Default fallback
|
||||
|
||||
useEffect(() => {
|
||||
const calculateOptimalSize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const element = containerRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// Use the larger dimension (width or height) as base
|
||||
const displaySize = Math.max(rect.width, rect.height);
|
||||
|
||||
// Account for device pixel ratio for crisp images on high DPI displays
|
||||
const targetSize = Math.round(displaySize * dpiMultiplier);
|
||||
|
||||
// Clamp to min/max bounds
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
|
||||
// Find the next larger available size to ensure quality
|
||||
const optimalSize = availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
|
||||
setImageSize(optimalSize);
|
||||
};
|
||||
|
||||
// Calculate initial size
|
||||
calculateOptimalSize();
|
||||
|
||||
// Recalculate on resize
|
||||
const resizeObserver = new ResizeObserver(calculateOptimalSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [minSize, maxSize, dpiMultiplier, availableSizes]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
imageSize,
|
||||
/** Get size for a specific display dimension */
|
||||
getSizeForDimension: (dimension: number) => {
|
||||
const targetSize = Math.round(dimension * dpiMultiplier);
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple function to get optimal image size for known dimensions
|
||||
*/
|
||||
export function getOptimalImageSize(
|
||||
displayWidth: number,
|
||||
displayHeight: number,
|
||||
options: Omit<UseResponsiveImageSizeOptions, 'availableSizes'> & { availableSizes?: number[] } = {}
|
||||
): number {
|
||||
const {
|
||||
minSize = 60,
|
||||
maxSize = 1200,
|
||||
dpiMultiplier = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
||||
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
|
||||
} = options;
|
||||
|
||||
const displaySize = Math.max(displayWidth, displayHeight);
|
||||
const targetSize = Math.round(displaySize * dpiMultiplier);
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
|
||||
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
258
lib/cache.ts
Normal file
258
lib/cache.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
// Types for caching (simplified versions to avoid circular imports)
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt?: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
playCount?: number;
|
||||
created: string;
|
||||
starred?: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
albumCount: number;
|
||||
starred?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
interface Song {
|
||||
id: string;
|
||||
parent: string;
|
||||
isDir: boolean;
|
||||
title: string;
|
||||
artist?: string;
|
||||
artistId?: string;
|
||||
album?: string;
|
||||
albumId?: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
coverArt?: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
suffix?: string;
|
||||
starred?: string;
|
||||
duration?: number;
|
||||
bitRate?: number;
|
||||
path?: string;
|
||||
playCount?: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface CacheItem<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
defaultTTL: number; // Time to live in milliseconds
|
||||
maxSize: number; // Maximum number of items in cache
|
||||
}
|
||||
|
||||
class Cache<T> {
|
||||
private cache = new Map<string, CacheItem<T>>();
|
||||
private config: CacheConfig;
|
||||
|
||||
constructor(config: CacheConfig = { defaultTTL: 24 * 60 * 60 * 1000, maxSize: 1000 }) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
set(key: string, data: T, ttl?: number): void {
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (ttl || this.config.defaultTTL);
|
||||
|
||||
// Remove expired items before adding new one
|
||||
this.cleanup();
|
||||
|
||||
// If cache is at max size, remove oldest item
|
||||
if (this.cache.size >= this.config.maxSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): T | null {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
// Check if item has expired
|
||||
if (Date.now() > item.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.data;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this.cleanup();
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
this.cleanup();
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now > item.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
getStats() {
|
||||
this.cleanup();
|
||||
const items = Array.from(this.cache.values());
|
||||
const totalSize = items.length;
|
||||
const oldestItem = items.reduce((oldest, item) =>
|
||||
!oldest || item.timestamp < oldest.timestamp ? item : oldest, null as CacheItem<T> | null);
|
||||
const newestItem = items.reduce((newest, item) =>
|
||||
!newest || item.timestamp > newest.timestamp ? item : newest, null as CacheItem<T> | null);
|
||||
|
||||
return {
|
||||
size: totalSize,
|
||||
maxSize: this.config.maxSize,
|
||||
oldestTimestamp: oldestItem?.timestamp,
|
||||
newestTimestamp: newestItem?.timestamp,
|
||||
defaultTTL: this.config.defaultTTL
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Specific cache instances
|
||||
export const albumCache = new Cache<Album[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 500 }); // 24 hours
|
||||
export const artistCache = new Cache<Artist[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 200 }); // 24 hours
|
||||
export const songCache = new Cache<Song[]>({ defaultTTL: 12 * 60 * 60 * 1000, maxSize: 1000 }); // 12 hours
|
||||
export const imageCache = new Cache<string>({ defaultTTL: 7 * 24 * 60 * 60 * 1000, maxSize: 1000 }); // 7 days for image URLs
|
||||
|
||||
// Cache management utilities
|
||||
export const CacheManager = {
|
||||
clearAll() {
|
||||
albumCache.clear();
|
||||
artistCache.clear();
|
||||
songCache.clear();
|
||||
imageCache.clear();
|
||||
|
||||
// Also clear localStorage cache data
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
albums: albumCache.getStats(),
|
||||
artists: artistCache.getStats(),
|
||||
songs: songCache.getStats(),
|
||||
images: imageCache.getStats()
|
||||
};
|
||||
},
|
||||
|
||||
getCacheSizeBytes() {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
|
||||
let size = 0;
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
|
||||
size += localStorage.getItem(key)?.length || 0;
|
||||
}
|
||||
});
|
||||
return size;
|
||||
}
|
||||
};
|
||||
|
||||
// Persistent cache for localStorage
|
||||
export const PersistentCache = {
|
||||
set<T>(key: string, data: T, ttl: number = 24 * 60 * 60 * 1000): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const item: CacheItem<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + ttl
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(`cache-${key}`, JSON.stringify(item));
|
||||
} catch (error) {
|
||||
console.warn('Failed to store in localStorage cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`cache-${key}`);
|
||||
if (!stored) return null;
|
||||
|
||||
const item: CacheItem<T> = JSON.parse(stored);
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > item.expiresAt) {
|
||||
localStorage.removeItem(`cache-${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.data;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read from localStorage cache:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
delete(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(`cache-${key}`);
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith('cache-')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a Gravatar URL from an email address
|
||||
* @param email - The email address
|
||||
* @param size - The size of the image (default: 80)
|
||||
* @param defaultImage - Default image type if no Gravatar found (default: 'identicon')
|
||||
* @returns The Gravatar URL
|
||||
*/
|
||||
export function getGravatarUrl(
|
||||
email: string,
|
||||
size: number = 80,
|
||||
defaultImage: string = 'identicon'
|
||||
): 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');
|
||||
|
||||
// Construct the Gravatar URL
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultImage}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Gravatar URL with retina support (2x size)
|
||||
* @param email - The email address
|
||||
* @param size - The base size of the image
|
||||
* @param defaultImage - Default image type if no Gravatar found
|
||||
* @returns The Gravatar URL at 2x resolution
|
||||
*/
|
||||
export function getGravatarUrlRetina(
|
||||
email: string,
|
||||
size: number = 80,
|
||||
defaultImage: string = 'identicon'
|
||||
): string {
|
||||
return getGravatarUrl(email, size * 2, defaultImage);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Utility functions for calculating optimal image sizes for different contexts
|
||||
*/
|
||||
|
||||
export interface ImageSizeContext {
|
||||
/** The display width in CSS pixels */
|
||||
displayWidth: number;
|
||||
/** The display height in CSS pixels */
|
||||
displayHeight: number;
|
||||
/** Device pixel ratio for high-DPI displays */
|
||||
devicePixelRatio?: number;
|
||||
/** Additional scaling factor (e.g., for hover effects) */
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the optimal image size for the given context
|
||||
* Takes into account device pixel ratio and potential scaling effects
|
||||
*/
|
||||
export function calculateOptimalImageSize(context: ImageSizeContext): number {
|
||||
const { displayWidth, displayHeight, devicePixelRatio = 1, scaleFactor = 1.1 } = context;
|
||||
|
||||
// Use the larger dimension to ensure we cover the entire display area
|
||||
const baseDimension = Math.max(displayWidth, displayHeight);
|
||||
|
||||
// Account for device pixel ratio and potential scaling
|
||||
const optimalSize = Math.ceil(baseDimension * devicePixelRatio * scaleFactor);
|
||||
|
||||
// Cap at reasonable maximum to avoid excessive bandwidth usage
|
||||
return Math.min(optimalSize, 1200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal image size for common component contexts
|
||||
* All sizes are clean divisions of 1200 for optimal scaling
|
||||
*/
|
||||
export const ImageSizes = {
|
||||
// Small thumbnails in lists - 1200/20 = 60, rounded to 64 for better display
|
||||
THUMBNAIL: 60,
|
||||
|
||||
// Small album covers in compact views - 1200/10 = 120
|
||||
SMALL_ALBUM: 120,
|
||||
|
||||
// Medium album covers in grid views - 1200/5 = 240
|
||||
MEDIUM_ALBUM: 240,
|
||||
|
||||
// Large album covers in detail views - 1200/3 = 400
|
||||
LARGE_ALBUM: 400,
|
||||
|
||||
// Extra large for full-screen displays - 1200/2 = 600
|
||||
XLARGE_ALBUM: 600,
|
||||
|
||||
// Full resolution - 1200/1 = 1200
|
||||
FULL_ALBUM: 1200,
|
||||
|
||||
// Artist images
|
||||
ARTIST_SMALL: 120, // 1200/10
|
||||
ARTIST_MEDIUM: 240, // 1200/5
|
||||
ARTIST_LARGE: 400, // 1200/3
|
||||
|
||||
// Player images
|
||||
PLAYER_MINI: 60, // 1200/20
|
||||
PLAYER_COMPACT: 120, // 1200/10
|
||||
PLAYER_FULL: 400, // 1200/3
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get responsive image size based on container and viewport
|
||||
*/
|
||||
export function getResponsiveImageSize(
|
||||
containerWidth: number,
|
||||
viewportWidth: number = typeof window !== 'undefined' ? window?.innerWidth || 1920 : 1920,
|
||||
devicePixelRatio: number = typeof window !== 'undefined' ? window?.devicePixelRatio || 1 : 1
|
||||
): number {
|
||||
let targetSize: number;
|
||||
|
||||
// Determine base size based on container and viewport
|
||||
// All sizes are clean divisions of 1200
|
||||
if (containerWidth <= 60) {
|
||||
targetSize = ImageSizes.THUMBNAIL; // 60px
|
||||
} else if (containerWidth <= 120) {
|
||||
targetSize = ImageSizes.SMALL_ALBUM; // 120px
|
||||
} else if (containerWidth <= 240 || viewportWidth <= 768) {
|
||||
targetSize = ImageSizes.MEDIUM_ALBUM; // 240px
|
||||
} else if (containerWidth <= 400 || viewportWidth <= 1024) {
|
||||
targetSize = ImageSizes.LARGE_ALBUM; // 400px
|
||||
} else if (containerWidth <= 600 || viewportWidth <= 1440) {
|
||||
targetSize = ImageSizes.XLARGE_ALBUM; // 600px
|
||||
} else {
|
||||
targetSize = ImageSizes.FULL_ALBUM; // 1200px
|
||||
}
|
||||
|
||||
// Apply device pixel ratio but ensure we stay within clean divisions of 1200
|
||||
const scaledSize = Math.ceil(targetSize * devicePixelRatio);
|
||||
|
||||
// Round to nearest clean division of 1200
|
||||
const divisions = [60, 120, 240, 400, 600, 1200];
|
||||
return divisions.find(size => size >= scaledSize) || 1200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get optimal image size for a container
|
||||
* Returns clean divisions of 1200 for optimal scaling
|
||||
*/
|
||||
export function useOptimalImageSize(
|
||||
width: number,
|
||||
height: number = width,
|
||||
scaleFactor: number = 1.1
|
||||
): number {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR fallback - return appropriate size based on dimensions
|
||||
return getResponsiveImageSize(width, 1920, 1);
|
||||
}
|
||||
|
||||
const optimalSize = calculateOptimalImageSize({
|
||||
displayWidth: width,
|
||||
displayHeight: height,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
scaleFactor,
|
||||
});
|
||||
|
||||
// Round to nearest clean division of 1200
|
||||
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
655
lib/indexeddb.ts
@@ -1,655 +0,0 @@
|
||||
'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();
|
||||
@@ -1,347 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache';
|
||||
|
||||
export interface NavidromeConfig {
|
||||
serverUrl: string;
|
||||
@@ -67,7 +68,6 @@ export interface Song {
|
||||
artistId: string;
|
||||
type: string;
|
||||
starred?: string;
|
||||
replayGain?: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
@@ -110,26 +110,6 @@ export interface ArtistInfo {
|
||||
similarArtist?: Artist[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
scrobblingEnabled: boolean;
|
||||
maxBitRate?: number;
|
||||
adminRole: boolean;
|
||||
settingsRole: boolean;
|
||||
downloadRole: boolean;
|
||||
uploadRole: boolean;
|
||||
playlistRole: boolean;
|
||||
coverArtRole: boolean;
|
||||
commentRole: boolean;
|
||||
podcastRole: boolean;
|
||||
streamRole: boolean;
|
||||
jukeboxRole: boolean;
|
||||
shareRole: boolean;
|
||||
videoConversionRole: boolean;
|
||||
avatarLastChanged?: string;
|
||||
}
|
||||
|
||||
class NavidromeAPI {
|
||||
private config: NavidromeConfig;
|
||||
private clientName = 'miceclient';
|
||||
@@ -191,12 +171,6 @@ class NavidromeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInfo(): Promise<User> {
|
||||
const response = await this.makeRequest('getUser', { username: this.config.username });
|
||||
const userData = response.user as User;
|
||||
return userData;
|
||||
}
|
||||
|
||||
async getArtists(): Promise<Artist[]> {
|
||||
const response = await this.makeRequest('getArtists');
|
||||
const artists: Artist[] = [];
|
||||
@@ -214,21 +188,12 @@ 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');
|
||||
}
|
||||
const response = await this.makeRequest('getArtist', { id: artistId });
|
||||
const artistData = response.artist as Artist & { album?: Album[] };
|
||||
return {
|
||||
artist: artistData,
|
||||
albums: artistData.album || []
|
||||
};
|
||||
}
|
||||
|
||||
async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise<Album[]> {
|
||||
@@ -339,23 +304,6 @@ 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);
|
||||
|
||||
@@ -1,782 +0,0 @@
|
||||
'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();
|
||||
11
lib/posthog.ts
Normal file
11
lib/posthog.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
|
||||
export default function PostHogClient() {
|
||||
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
|
||||
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
// capture_pageview: 'history_change',
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
})
|
||||
return posthogClient
|
||||
}
|
||||
16
lib/utils.ts
16
lib/utils.ts
@@ -4,19 +4,3 @@ 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]}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
qualities: [50, 75, 100],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
@@ -12,8 +11,6 @@ const nextConfig = {
|
||||
hostname: "**",
|
||||
}
|
||||
],
|
||||
minimumCacheTTL: 60,
|
||||
// unoptimized: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
@@ -47,12 +44,30 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: "default-src 'self' *; connect-src 'self' *; script-src 'self'",
|
||||
value: "default-src 'self'; script-src 'self'",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/ingest/static/:path*',
|
||||
destination: 'https://us-assets.i.posthog.com/static/:path*',
|
||||
},
|
||||
{
|
||||
source: '/ingest/:path*',
|
||||
destination: 'https://us.i.posthog.com/:path*',
|
||||
},
|
||||
{
|
||||
source: '/ingest/decide',
|
||||
destination: 'https://us.i.posthog.com/decide',
|
||||
},
|
||||
];
|
||||
},
|
||||
// This is required to support PostHog trailing slash API requests
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
127
package.json
127
package.json
@@ -13,90 +13,77 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"axios": "^1.8.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"colorthief": "^2.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-resizable-panels": "^4.5.1",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-node": "^5.1.1",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"recharts": "^3.0.2",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^3.25.70"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.4",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "5.9.3"
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.30",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"overrides": {
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
4564
pnpm-lock.yaml
generated
4564
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- unrs-resolver
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 869 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user