Merge pull request #7 from sillyangel/v1
Add Docker support, switch to date-based versioning, and enhance starred songs and Last.fm integration - Added Docker configuration with health checks - Adopted date-based versioning (e.g., 2025.07.02) - Added starred songs management to AlbumPage - Introduced ArtistBio with Last.fm data and popular songs - Enhanced AudioPlayer with standalone Last.fm scrobbling - Simplified tracklist UI and removed unused elements - Cleaned up unused imports and deprecated config files
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env.local
|
||||
.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
.nyc_output
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
.cache
|
||||
.parcel-cache
|
||||
dist
|
||||
build
|
||||
.vercel
|
||||
.netlify
|
||||
.turbo
|
||||
.github
|
||||
4xnored.png
|
||||
21
.env.docker
Normal file
21
.env.docker
Normal file
@@ -0,0 +1,21 @@
|
||||
# Docker Environment Configuration
|
||||
# Copy this file to .env and modify the values as needed
|
||||
|
||||
# Host configuration
|
||||
HOST_PORT=3000
|
||||
PORT=3000
|
||||
|
||||
# Navidrome Server Configuration (OPTIONAL)
|
||||
# If not provided, the app will prompt you to configure these settings
|
||||
# NAVIDROME_URL=http://localhost:4533
|
||||
# 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
|
||||
# NAVIDROME_PASSWORD=your_password
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=e88d8b2
|
||||
NEXT_PUBLIC_COMMIT_SHA=a854604
|
||||
|
||||
39
.idx/dev.nix
39
.idx/dev.nix
@@ -1,39 +0,0 @@
|
||||
{ pkgs, ... }: {
|
||||
|
||||
# Which nixpkgs channel to use.
|
||||
channel = "stable-23.11"; # or "unstable"
|
||||
|
||||
# Use https://search.nixos.org/packages to find packages
|
||||
packages = [
|
||||
pkgs.corepack
|
||||
];
|
||||
|
||||
# Sets environment variables in the workspace
|
||||
# env = {
|
||||
# SOME_ENV_VAR = "hello";
|
||||
# };
|
||||
|
||||
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
|
||||
# idx.extensions = [
|
||||
# "angular.ng-template"
|
||||
# ];
|
||||
|
||||
# Enable previews and customize configuration
|
||||
idx.previews = {
|
||||
enable = true;
|
||||
previews = {
|
||||
web = {
|
||||
command = [
|
||||
"pnpm"
|
||||
"run"
|
||||
"dev"
|
||||
"--port"
|
||||
"$PORT"
|
||||
];
|
||||
manager = "web";
|
||||
# Optionally, specify a directory that contains your web app
|
||||
# cwd = "app/client";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"IDX.aI.enableInlineCompletion": true,
|
||||
"IDX.aI.enableCodebaseIndexing": true
|
||||
}
|
||||
171
DOCKER.md
Normal file
171
DOCKER.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Docker Deployment
|
||||
|
||||
This application can be easily deployed using Docker with configurable environment variables.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Run
|
||||
|
||||
```bash
|
||||
# Run using pre-built image (app will prompt for Navidrome config)
|
||||
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||
|
||||
# Or build locally
|
||||
docker build -t mice .
|
||||
docker run -p 3000:3000 mice
|
||||
|
||||
# Run with pre-configured Navidrome settings
|
||||
docker run -p 3000:3000 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
|
||||
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
|
||||
-e PORT=3000 \
|
||||
ghcr.io/sillyangel/mice:latest
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
1. Copy the environment template:
|
||||
|
||||
```bash
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
2. Edit `.env` with your configuration:
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
3. Start the application:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Note**: The default docker-compose.yml uses the pre-built image `ghcr.io/sillyangel/mice:latest`.
|
||||
|
||||
For local development, you can use the override example:
|
||||
|
||||
```bash
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
# This will build locally instead of using the pre-built image
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
All configuration is done through environment variables. If Navidrome server configuration is not provided via environment variables, the application will automatically prompt you to configure it within the client interface.
|
||||
|
||||
### Optional Variables
|
||||
|
||||
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server (optional - app will prompt if not set)
|
||||
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username (optional - app will prompt if not set)
|
||||
- `NEXT_PUBLIC_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`)
|
||||
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
|
||||
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Setup (App will prompt for configuration)
|
||||
|
||||
```bash
|
||||
# Using pre-built image - app will ask for Navidrome server details on first launch
|
||||
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||
|
||||
# Or build locally
|
||||
docker build -t mice .
|
||||
docker run -p 3000:3000 mice
|
||||
```
|
||||
|
||||
### Pre-configured Development Setup
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_USERNAME=admin \
|
||||
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=admin \
|
||||
ghcr.io/sillyangel/mice:latest
|
||||
```
|
||||
|
||||
### Pre-configured Production Setup
|
||||
|
||||
```bash
|
||||
docker run -p 80:3000 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_URL=https://music.yourdomain.com \
|
||||
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_user \
|
||||
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_secure_password \
|
||||
-e PORT=3000 \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/sillyangel/mice:latest
|
||||
```
|
||||
|
||||
### Using Environment File
|
||||
|
||||
#### Option 1: Let the app prompt for configuration
|
||||
|
||||
Create a minimal `.env` file:
|
||||
|
||||
```env
|
||||
PORT=3000
|
||||
HOST_PORT=80
|
||||
```
|
||||
|
||||
#### Option 2: Pre-configure Navidrome settings
|
||||
|
||||
Create a `.env` file with Navidrome configuration:
|
||||
|
||||
```env
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
NAVIDROME_USERNAME=your_user
|
||||
NAVIDROME_PASSWORD=your_secure_password
|
||||
PORT=3000
|
||||
HOST_PORT=80
|
||||
```
|
||||
|
||||
Then run either way:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The Docker Compose setup includes a health check that verifies the application is responding correctly. You can check the health status with:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection refused**: Ensure your Navidrome server is accessible from the Docker container
|
||||
2. **Authentication failed**: Verify your username and password are correct
|
||||
3. **Port conflicts**: Change the `HOST_PORT` if port 3000 is already in use
|
||||
|
||||
### Logs
|
||||
|
||||
View application logs:
|
||||
|
||||
```bash
|
||||
# Docker run
|
||||
docker logs <container_name>
|
||||
|
||||
# Docker compose
|
||||
docker-compose logs -f mice
|
||||
```
|
||||
|
||||
### Container Shell Access
|
||||
|
||||
Access the container for debugging:
|
||||
|
||||
```bash
|
||||
# Docker run
|
||||
docker exec -it <container_name> sh
|
||||
|
||||
# Docker compose
|
||||
docker-compose exec mice sh
|
||||
```
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Use Node.js 22 Alpine for smaller image size
|
||||
FROM node:22-alpine
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm@9.15.3
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set default environment variables (can be overridden at runtime)
|
||||
# Navidrome configuration is optional - app will prompt if not provided
|
||||
ENV NEXT_PUBLIC_NAVIDROME_URL=""
|
||||
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=""
|
||||
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=""
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST=""
|
||||
ENV PORT=3000
|
||||
|
||||
# Generate git commit hash for build info (fallback if not available)
|
||||
RUN echo "NEXT_PUBLIC_COMMIT_SHA=docker-build" > .env.local
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# Expose the port
|
||||
EXPOSE $PORT
|
||||
|
||||
# Start the application
|
||||
CMD ["sh", "-c", "pnpm start -p $PORT"]
|
||||
40
README.md
40
README.md
@@ -44,12 +44,14 @@ pnpm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env.local` with your Navidrome server details:
|
||||
Edit `.env` with your Navidrome server details:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
|
||||
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
|
||||
NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
3. **Run the development server**
|
||||
@@ -60,6 +62,42 @@ pnpm dev
|
||||
|
||||
Open [http://localhost:40625](http://localhost:40625) in your browser.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
For easy deployment using Docker:
|
||||
|
||||
### Quick Docker Setup
|
||||
|
||||
```bash
|
||||
# Run using pre-built image (app will prompt for Navidrome configuration)
|
||||
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||
|
||||
# Or build locally
|
||||
docker build -t mice .
|
||||
docker run -p 3000:3000 mice
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Copy environment template and configure
|
||||
cp .env.docker .env
|
||||
# Edit .env with your settings (optional - app can prompt)
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Pre-configured Docker Run
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \
|
||||
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
|
||||
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
|
||||
ghcr.io/sillyangel/mice:latest
|
||||
```
|
||||
|
||||
📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
|
||||
|
||||
## Migration from Firebase
|
||||
|
||||
This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting.
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { Play, Heart, User, Plus } from 'lucide-react';
|
||||
import { Play, Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||
import Loading from "@/app/components/loading";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -20,8 +19,9 @@ export default function AlbumPage() {
|
||||
const [tracklist, setTracklist] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, addToQueue, currentTrack } = useAudioPlayer();
|
||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,6 +34,13 @@ export default function AlbumPage() {
|
||||
setAlbum(albumData.album);
|
||||
setTracklist(albumData.songs);
|
||||
setIsStarred(!!albumData.album.starred);
|
||||
|
||||
// Initialize starred songs state
|
||||
const starredSongIds = new Set(
|
||||
albumData.songs.filter(song => song.starred).map(song => song.id)
|
||||
);
|
||||
setStarredSongs(starredSongIds);
|
||||
|
||||
console.log(`Album found: ${albumData.album.name}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch album:', error);
|
||||
@@ -63,6 +70,26 @@ export default function AlbumPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSongStar = async (song: Song) => {
|
||||
try {
|
||||
const isCurrentlyStarred = starredSongs.has(song.id);
|
||||
|
||||
if (isCurrentlyStarred) {
|
||||
await unstarItem(song.id, 'song');
|
||||
setStarredSongs(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(song.id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
await starItem(song.id, 'song');
|
||||
setStarredSongs(prev => new Set(prev).add(song.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to star/unstar song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -80,26 +107,6 @@ export default function AlbumPage() {
|
||||
console.error('Failed to play album from track:', error);
|
||||
}
|
||||
};
|
||||
const handleAddToQueue = (song: Song) => {
|
||||
if (!api) {
|
||||
console.error('Navidrome API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const track = {
|
||||
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
|
||||
};
|
||||
|
||||
addToQueue(track);
|
||||
};
|
||||
|
||||
const isCurrentlyPlaying = (song: Song): boolean => {
|
||||
return currentTrack?.id === song.id;
|
||||
@@ -160,23 +167,15 @@ export default function AlbumPage() {
|
||||
{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 ${
|
||||
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
|
||||
}`}
|
||||
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">
|
||||
{isCurrentlyPlaying(song) ? (
|
||||
<div className="w-4 h-4 mx-auto">
|
||||
<div className="w-full h-full bg-primary rounded-full animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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 */}
|
||||
@@ -190,7 +189,6 @@ export default function AlbumPage() {
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span className="truncate">{song.artist}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,17 +200,20 @@ export default function AlbumPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddToQueue(song);
|
||||
handleSongStar(song);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Album, Artist } from '@/lib/navidrome';
|
||||
import { Album, Artist, Song } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||
import { PopularSongs } from '@/app/components/PopularSongs';
|
||||
import { SimilarArtists } from '@/app/components/SimilarArtists';
|
||||
import { ArtistBio } from '@/app/components/ArtistBio';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Heart, Play } from 'lucide-react';
|
||||
@@ -17,6 +20,7 @@ export default function ArtistPage() {
|
||||
const { artist: artistId } = useParams();
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
||||
const [popularSongs, setPopularSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [artist, setArtist] = useState<Artist | null>(null);
|
||||
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||
@@ -29,11 +33,19 @@ export default function ArtistPage() {
|
||||
const fetchArtistData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (artistId) {
|
||||
if (artistId && api) {
|
||||
const artistData = await getArtist(artistId as string);
|
||||
setArtist(artistData.artist);
|
||||
setArtistAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
|
||||
// Fetch popular songs for the artist
|
||||
try {
|
||||
const songs = await api.getArtistTopSongs(artistData.artist.name, 10);
|
||||
setPopularSongs(songs);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch popular songs:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist data:', error);
|
||||
@@ -42,7 +54,7 @@ export default function ArtistPage() {
|
||||
};
|
||||
|
||||
fetchArtistData();
|
||||
}, [artistId, getArtist]);
|
||||
}, [artistId, getArtist, api]);
|
||||
|
||||
const handleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -135,10 +147,18 @@ export default function ArtistPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Section */}
|
||||
<ArtistBio artistName={artist.name} />
|
||||
|
||||
{/* Popular Songs Section */}
|
||||
{popularSongs.length > 0 && (
|
||||
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||
)}
|
||||
|
||||
{/* Albums Section */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Albums</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Discography</h2>
|
||||
<ScrollArea>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
||||
{artistAlbums.map((album) => (
|
||||
@@ -155,6 +175,12 @@ export default function ArtistPage() {
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Similar Artists Section */}
|
||||
<SimilarArtists artistName={artist.name} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,8 +119,8 @@ export default function BrowsePage() {
|
||||
<ArtistIcon
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
className="flex-shrink-0"
|
||||
size={150}
|
||||
className="flex-shrink-0 overflow-hidden"
|
||||
size={190}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
106
app/components/ArtistBio.tsx
Normal file
106
app/components/ArtistBio.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface ArtistBioProps {
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function ArtistBio({ artistName }: ArtistBioProps) {
|
||||
const [bio, setBio] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [lastFmUrl, setLastFmUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArtistInfo = async () => {
|
||||
if (!lastFmAPI.isAvailable()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const artistInfo = await lastFmAPI.getArtistInfo(artistName);
|
||||
if (artistInfo?.bio?.summary) {
|
||||
// Clean up the bio text (remove HTML tags and Last.fm links)
|
||||
let cleanBio = artistInfo.bio.summary
|
||||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Remove the "Read more on Last.fm" part
|
||||
cleanBio = cleanBio.replace(/Read more on Last\.fm.*$/i, '').trim();
|
||||
|
||||
setBio(cleanBio);
|
||||
setLastFmUrl(`https://www.last.fm/music/${encodeURIComponent(artistName)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist bio:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchArtistInfo();
|
||||
}, [artistName]);
|
||||
|
||||
if (!lastFmAPI.isAvailable() || loading || !bio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldTruncate = bio.length > 300;
|
||||
const displayBio = shouldTruncate && !expanded ? bio.substring(0, 300) + '...' : bio;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">About</h2>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{displayBio}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{lastFmUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
<a
|
||||
href={lastFmUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Last.fm
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
||||
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand, FaShuffle } from "react-icons/fa6";
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
|
||||
export const AudioPlayer: React.FC = () => {
|
||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle } = useAudioPlayer();
|
||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||
const router = useRouter();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
||||
@@ -24,14 +26,49 @@ export const AudioPlayer: React.FC = () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
const { toast } = useToast();
|
||||
|
||||
// Last.fm scrobbler integration
|
||||
// Last.fm scrobbler integration (Navidrome)
|
||||
const {
|
||||
onTrackStart,
|
||||
onTrackPlay,
|
||||
onTrackPause,
|
||||
onTrackProgress,
|
||||
onTrackEnd,
|
||||
onTrackStart: navidromeOnTrackStart,
|
||||
onTrackPlay: navidromeOnTrackPlay,
|
||||
onTrackPause: navidromeOnTrackPause,
|
||||
onTrackProgress: navidromeOnTrackProgress,
|
||||
onTrackEnd: navidromeOnTrackEnd,
|
||||
} = useLastFmScrobbler();
|
||||
|
||||
// Standalone Last.fm integration
|
||||
const {
|
||||
onTrackStart: standaloneOnTrackStart,
|
||||
onTrackPlay: standaloneOnTrackPlay,
|
||||
onTrackPause: standaloneOnTrackPause,
|
||||
onTrackProgress: standaloneOnTrackProgress,
|
||||
onTrackEnd: standaloneOnTrackEnd,
|
||||
} = useStandaloneLastFm();
|
||||
|
||||
// Combined Last.fm handlers
|
||||
const onTrackStart = useCallback((track: Track) => {
|
||||
navidromeOnTrackStart(track);
|
||||
standaloneOnTrackStart(track);
|
||||
}, [navidromeOnTrackStart, standaloneOnTrackStart]);
|
||||
|
||||
const onTrackPlay = useCallback((track: Track) => {
|
||||
navidromeOnTrackPlay(track);
|
||||
standaloneOnTrackPlay(track);
|
||||
}, [navidromeOnTrackPlay, standaloneOnTrackPlay]);
|
||||
|
||||
const onTrackPause = useCallback((currentTime: number) => {
|
||||
navidromeOnTrackPause(currentTime);
|
||||
standaloneOnTrackPause(currentTime);
|
||||
}, [navidromeOnTrackPause, standaloneOnTrackPause]);
|
||||
|
||||
const onTrackProgress = useCallback((track: Track, currentTime: number, duration: number) => {
|
||||
navidromeOnTrackProgress(track, currentTime, duration);
|
||||
standaloneOnTrackProgress(track, currentTime, duration);
|
||||
}, [navidromeOnTrackProgress, standaloneOnTrackProgress]);
|
||||
|
||||
const onTrackEnd = useCallback((track: Track, currentTime: number, duration: number) => {
|
||||
navidromeOnTrackEnd(track, currentTime, duration);
|
||||
standaloneOnTrackEnd(track, currentTime, duration);
|
||||
}, [navidromeOnTrackEnd, standaloneOnTrackEnd]);
|
||||
|
||||
const handleOpenQueue = () => {
|
||||
setIsFullScreen(false);
|
||||
@@ -333,14 +370,27 @@ export const AudioPlayer: React.FC = () => {
|
||||
height={40}
|
||||
className="w-10 h-10 rounded-md flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 mx-3 group">
|
||||
<div className="flex-1 min-w-0 mx-3">
|
||||
<div className="overflow-hidden">
|
||||
<p className="font-semibold text-sm whitespace-nowrap group-hover:animate-scroll">
|
||||
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
|
||||
{currentTrack.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
{/* Heart icon for favoriting */}
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
}}
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-3 h-3" />
|
||||
@@ -363,52 +413,84 @@ export const AudioPlayer: React.FC = () => {
|
||||
// Compact floating player (default state)
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50">
|
||||
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-3 pb-0 cursor-pointer hover:scale-[1.01] transition-transform">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
|
||||
<div className="flex items-center">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||
alt={currentTrack.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-10 h-10 rounded-md mr-3 flex-shrink-0"
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-md mr-4 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold truncate text-sm">{currentTrack.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
<p className="font-semibold truncate text-base">{currentTrack.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
{/* faviorte icon or smthing here */}
|
||||
</div>
|
||||
{/* Control buttons */}
|
||||
<button
|
||||
onClick={toggleShuffle} className={`p-1.5 hover:bg-gray-700/50 rounded-full transition-colors ${ shuffle ? 'text-primary bg-primary/20' : '' }`} title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}>
|
||||
<FaShuffle className="w-3 h-3" />
|
||||
|
||||
{/* Center section with controls and progress */}
|
||||
<div className="flex flex-col items-center flex-1 justify-center">
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${shuffle ? 'text-primary bg-primary/20' : ''}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-3 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
}}
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* <div className="flex items-center space-x-2 w-80">
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">
|
||||
{formatTime(audioCurrent?.currentTime ?? 0)}
|
||||
</span>
|
||||
<Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
||||
<span className="text-xs text-muted-foreground w-8">
|
||||
{formatTime(audioCurrent?.duration ?? 0)}
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 flex-1">
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
title="Full Screen"
|
||||
>
|
||||
<FaExpand className="w-3 h-3" />
|
||||
<FaExpand className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsMinimized(true)}
|
||||
title="Minimize"
|
||||
>
|
||||
<FaCompress className="w-3 h-3" />
|
||||
<FaCompress className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,16 +506,4 @@ export const AudioPlayer: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// {/* Progress bar */}
|
||||
// <div className="flex items-center space-x-2">
|
||||
// <span className="text-xs text-muted-foreground w-8 text-right">
|
||||
// {formatTime(audioCurrent?.currentTime ?? 0)}
|
||||
// </span>
|
||||
// <Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
||||
// <span className="text-xs text-muted-foreground w-8">
|
||||
// {formatTime(audioCurrent?.duration ?? 0)}
|
||||
// </span>
|
||||
// </div>
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export interface Track {
|
||||
albumId: string;
|
||||
artistId: string;
|
||||
autoPlay?: boolean; // Flag to control auto-play
|
||||
starred?: boolean; // Flag for starred/favorited tracks
|
||||
}
|
||||
|
||||
interface AudioPlayerContextProps {
|
||||
@@ -39,6 +40,8 @@ interface AudioPlayerContextProps {
|
||||
playArtist: (artistId: string) => Promise<void>;
|
||||
playedTracks: Track[];
|
||||
clearHistory: () => void;
|
||||
toggleCurrentTrackStar: () => Promise<void>;
|
||||
updateTrackStarred: (trackId: string, starred: boolean) => void;
|
||||
}
|
||||
|
||||
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
||||
@@ -104,7 +107,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -577,7 +581,75 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
shuffleAllAlbums,
|
||||
playArtist,
|
||||
playedTracks,
|
||||
clearHistory
|
||||
clearHistory,
|
||||
toggleCurrentTrackStar: async () => {
|
||||
if (!currentTrack || !api) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "No track currently playing or API not configured",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newStarredStatus = !currentTrack.starred;
|
||||
|
||||
try {
|
||||
if (newStarredStatus) {
|
||||
await api.star(currentTrack.id, 'song');
|
||||
} else {
|
||||
await api.unstar(currentTrack.id, 'song');
|
||||
}
|
||||
|
||||
// Update the current track state
|
||||
setCurrentTrack((prev) => prev ? { ...prev, starred: newStarredStatus } : null);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update track starred status:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to update track favorite status",
|
||||
});
|
||||
}
|
||||
},
|
||||
updateTrackStarred: async (trackId: string, starred: boolean) => {
|
||||
if (!api) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Configuration Required",
|
||||
description: "Please configure Navidrome connection in settings",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (starred) {
|
||||
await api.star(trackId, 'song');
|
||||
} else {
|
||||
await api.unstar(trackId, 'song');
|
||||
}
|
||||
|
||||
// Update the current track state if it matches the updated track
|
||||
if (currentTrack?.id === trackId) {
|
||||
setCurrentTrack((prev) => prev ? { ...prev, starred } : null);
|
||||
}
|
||||
|
||||
// Also update queue if the track is in there
|
||||
setQueue((prev) => prev.map(track =>
|
||||
track.id === trackId ? { ...track, starred } : track
|
||||
));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update track starred status:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to update track favorite status",
|
||||
});
|
||||
}
|
||||
}
|
||||
}), [
|
||||
currentTrack,
|
||||
queue,
|
||||
@@ -598,7 +670,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
shuffleAllAlbums,
|
||||
playArtist,
|
||||
playedTracks,
|
||||
clearHistory
|
||||
clearHistory,
|
||||
api,
|
||||
toast
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { lrcLibClient } from '@/lib/lrclib';
|
||||
@@ -19,8 +20,15 @@ import {
|
||||
FaQuoteLeft,
|
||||
FaListUl
|
||||
} from "react-icons/fa6";
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
interface LyricLine {
|
||||
time: number;
|
||||
@@ -34,7 +42,8 @@ interface FullScreenPlayerProps {
|
||||
}
|
||||
|
||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
||||
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||
const router = useRouter();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
@@ -334,8 +343,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
|
||||
{currentTrack.name}
|
||||
</h1>
|
||||
<Link href={`/album/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
|
||||
{currentTrack.artist}
|
||||
<Link href={`/artist/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
|
||||
{currentTrack.artist}
|
||||
</Link>
|
||||
<Link href={`/album/${currentTrack.albumId}`} className="text-sm sm:text-base lg:text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
|
||||
{currentTrack.album}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -384,17 +396,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{lyrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||
}`}
|
||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||
>
|
||||
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleCurrentTrackStar}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 sm:w-5 sm:h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -410,6 +422,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
)}
|
||||
</button>
|
||||
|
||||
{lyrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||
}`}
|
||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||
>
|
||||
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showVolumeSlider && (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo }
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface NavidromeContextType {
|
||||
// API instance
|
||||
api: ReturnType<typeof getNavidromeAPI>;
|
||||
|
||||
// Data
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
@@ -387,6 +390,9 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
||||
}, [api, refreshData]);
|
||||
|
||||
const value: NavidromeContextType = {
|
||||
// API instance
|
||||
api,
|
||||
|
||||
// Data
|
||||
albums,
|
||||
artists,
|
||||
|
||||
151
app/components/PopularSongs.tsx
Normal file
151
app/components/PopularSongs.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
import Image from 'next/image';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Heart } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
interface PopularSongsProps {
|
||||
songs: Song[];
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
const { playTrack } = useAudioPlayer();
|
||||
const { starItem, unstarItem } = useNavidrome();
|
||||
const [songStates, setSongStates] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
songs.forEach(song => {
|
||||
initial[song.id] = !!song.starred;
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
const songToTrack = (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, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
};
|
||||
|
||||
const handlePlaySong = async (song: Song) => {
|
||||
try {
|
||||
const track = songToTrack(song);
|
||||
playTrack(track, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to play song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async (song: Song) => {
|
||||
try {
|
||||
const isStarred = songStates[song.id];
|
||||
if (isStarred) {
|
||||
await unstarItem(song.id, 'song');
|
||||
setSongStates(prev => ({ ...prev, [song.id]: false }));
|
||||
} else {
|
||||
await starItem(song.id, 'song');
|
||||
setSongStates(prev => ({ ...prev, [song.id]: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to star/unstar song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (songs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Popular Songs</h2>
|
||||
<div className="space-y-2">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 group"
|
||||
>
|
||||
{/* Rank */}
|
||||
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
|
||||
{song.coverArt && api && (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 96)}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-white hover:bg-white/20"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{song.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{song.album}</div>
|
||||
</div>
|
||||
|
||||
{/* Play Count */}
|
||||
{song.playCount && song.playCount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{song.playCount.toLocaleString()} plays
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-xs text-muted-foreground w-12 text-right">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Star Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleToggleStar(song)}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${songStates[song.id] ? 'text-red-500 fill-red-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { LoginForm } from "./start-screen";
|
||||
@@ -13,19 +14,56 @@ import Image from "next/image";
|
||||
|
||||
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
const { error } = useNavidrome();
|
||||
if (error) {
|
||||
|
||||
// Check if this is a first-time user
|
||||
const hasCompletedOnboarding = typeof window !== 'undefined'
|
||||
? localStorage.getItem('onboarding-completed')
|
||||
: false;
|
||||
|
||||
// Simple check: has config in localStorage or environment
|
||||
const hasAnyConfig = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check localStorage config
|
||||
const savedConfig = localStorage.getItem('navidrome-config');
|
||||
if (savedConfig) {
|
||||
try {
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config.serverUrl && config.username && config.password) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid config, continue to env check
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment variables (visible on client side with NEXT_PUBLIC_)
|
||||
if (process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
{/* top right add the logo located in /icon-192.png here and the word mice */}
|
||||
<div className="absolute top-4 left-4 flex items-center space-x-2">
|
||||
<Image src="/icon-192.png" alt="Logo" width={32} height={32} className="h-8 w-8" />
|
||||
<span className="text-xl font-semibold">mice | navidrome client</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
<LoginForm />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
@@ -43,6 +81,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
||||
<Ihateserverside>
|
||||
{children}
|
||||
</Ihateserverside>
|
||||
<WhatsNewPopup />
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeErrorBoundary>
|
||||
</NavidromeProvider>
|
||||
|
||||
96
app/components/SimilarArtists.tsx
Normal file
96
app/components/SimilarArtists.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SimilarArtist {
|
||||
name: string;
|
||||
url: string;
|
||||
image?: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SimilarArtistsProps {
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function SimilarArtists({ artistName }: SimilarArtistsProps) {
|
||||
const [similarArtists, setSimilarArtists] = useState<SimilarArtist[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarArtists = async () => {
|
||||
if (!lastFmAPI.isAvailable()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const similar = await lastFmAPI.getSimilarArtists(artistName, 6);
|
||||
if (similar?.artist) {
|
||||
setSimilarArtists(similar.artist);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch similar artists:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSimilarArtists();
|
||||
}, [artistName]);
|
||||
|
||||
const getArtistImage = (artist: SimilarArtist): string => {
|
||||
if (!artist.image || artist.image.length === 0) {
|
||||
return '/default-user.jpg';
|
||||
}
|
||||
|
||||
// Try to get medium or large image
|
||||
const mediumImage = artist.image.find(img => img.size === 'medium' || img.size === 'large');
|
||||
const anyImage = artist.image[artist.image.length - 1]; // Fallback to last image
|
||||
|
||||
return mediumImage?.['#text'] || anyImage?.['#text'] || '/default-user.jpg';
|
||||
};
|
||||
|
||||
if (!lastFmAPI.isAvailable() || loading || similarArtists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Fans also like</h2>
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{similarArtists.map((artist) => (
|
||||
<Link
|
||||
key={artist.name}
|
||||
href={`/artist/${encodeURIComponent(artist.name)}`}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<div className="w-32 space-y-2 group cursor-pointer">
|
||||
<div className="relative w-32 h-32 bg-muted rounded-full overflow-hidden">
|
||||
<Image
|
||||
src={getArtistImage(artist)}
|
||||
alt={artist.name}
|
||||
width={128}
|
||||
height={128}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{artist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
app/components/WhatsNewPopup.tsx
Normal file
217
app/components/WhatsNewPopup.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'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.02';
|
||||
|
||||
// Changelog data - add new versions at the top
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: '2025.07.02',
|
||||
title: 'July Mini Update',
|
||||
changes: [
|
||||
'New Favorites inside of the Home Page',
|
||||
'Server Status Indicator removed for better performance',
|
||||
'New Album Artwork component for consistency (along with the artists)'
|
||||
],
|
||||
breaking: [],
|
||||
fixes: []
|
||||
},
|
||||
{
|
||||
version: '2025.07.01',
|
||||
title: 'July New Month Update',
|
||||
changes: [
|
||||
'Integrated standalone Last.fm scrobbling support',
|
||||
'Added collapsible sidebar with icon-only mode',
|
||||
'Improved search and browsing experience',
|
||||
'Added history tracking for played songs',
|
||||
'New Library Artist Page',
|
||||
'Enhanced audio player with better controls',
|
||||
'Added settings page for customization options',
|
||||
'Introduced Whats New popup for version updates',
|
||||
'Improved UI consistency with new Badge component',
|
||||
'New Favorites page with album, song, and artist sections',
|
||||
'New Favortites inside of the Home Page',
|
||||
'Server Status Indicator removed for better performance',
|
||||
],
|
||||
breaking: [],
|
||||
fixes: [
|
||||
'Fixed issue with audio player not resuming playback after pause',
|
||||
'Resolved bug with search results not displaying correctly',
|
||||
'Improved performance for large libraries',
|
||||
'Fixed layout issues on smaller screens',
|
||||
'Resolved scrobbling issues with Last.fm integration'
|
||||
]
|
||||
},
|
||||
// Example previous version
|
||||
{
|
||||
version: '2025.06.15',
|
||||
title: 'June Final Update',
|
||||
changes: [
|
||||
'Added dark mode toggle',
|
||||
'Improved playlist management',
|
||||
],
|
||||
breaking: [],
|
||||
fixes: [
|
||||
'Fixed login bug',
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
type TabType = 'latest' | 'archive';
|
||||
|
||||
export function WhatsNewPopup() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tab, setTab] = useState<TabType>('latest');
|
||||
const [selectedArchive, setSelectedArchive] = useState(CHANGELOG[1]?.version || '');
|
||||
|
||||
useEffect(() => {
|
||||
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||
if (!hasCompletedOnboarding) return;
|
||||
const lastShownVersion = localStorage.getItem('whats-new-last-shown');
|
||||
if (lastShownVersion !== APP_VERSION) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem('whats-new-last-shown', APP_VERSION);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const currentVersionChangelog = CHANGELOG.find(entry => entry.version === APP_VERSION);
|
||||
const archiveChangelogs = CHANGELOG.filter(entry => entry.version !== APP_VERSION);
|
||||
|
||||
// For archive, show selected version
|
||||
const archiveChangelog = archiveChangelogs.find(entry => entry.version === selectedArchive) || archiveChangelogs[0];
|
||||
|
||||
if (!currentVersionChangelog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderChangelog = (changelog: typeof CHANGELOG[0]) => (
|
||||
<div className="space-y-6">
|
||||
{changelog.title && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">{changelog.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changelog.changes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||
✨ New Features & Improvements
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{changelog.changes.map((change, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-1">•</span>
|
||||
<span className="text-sm">{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changelog.fixes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||
🐛 Bug Fixes
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{changelog.fixes.map((fix, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">•</span>
|
||||
<span className="text-sm">{fix}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changelog.breaking.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||
⚠️ Breaking Changes
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{changelog.breaking.map((breaking, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-red-500 mt-1">•</span>
|
||||
<span className="text-sm">{breaking}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../../components/ui/context-menu"
|
||||
|
||||
import { Album } from "@/lib/navidrome"
|
||||
import { useNavidrome } from "./NavidromeContext"
|
||||
import Link from "next/link";
|
||||
import { useAudioPlayer } from "@/app/components/AudioPlayerContext";
|
||||
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||
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 } from "lucide-react";
|
||||
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||
|
||||
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
album: Album
|
||||
@@ -37,10 +42,10 @@ export function AlbumArtwork({
|
||||
className,
|
||||
...props
|
||||
}: AlbumArtworkProps) {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const router = useRouter();
|
||||
const { addAlbumToQueue } = useAudioPlayer();
|
||||
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
||||
const { playlists, starItem, unstarItem } = useNavidrome();
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/album/${album.id}`);
|
||||
@@ -57,6 +62,47 @@ export function AlbumArtwork({
|
||||
starItem(album.id, 'album');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const songs = await api.getAlbumSongs(album.id);
|
||||
if (songs.length > 0) {
|
||||
const tracks = songs.map((song: Song) => ({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
playTrack(tracks[0]);
|
||||
tracks.slice(1).forEach((track: Track) => addToQueue(track));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = async (id: string, type: 'song' | 'album' | 'artist', isStarred: boolean) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
if (isStarred) {
|
||||
await api.unstar(id, type);
|
||||
} else {
|
||||
await api.star(id, type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
// Get cover art URL with proper fallback
|
||||
const coverArtUrl = album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
@@ -66,7 +112,34 @@ export function AlbumArtwork({
|
||||
<div className={cn("space-y-3", className)} {...props}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div onClick={handleClick} className="overflow-hidden rounded-md">
|
||||
<Card key={album.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt)}
|
||||
alt={album.name}
|
||||
fill
|
||||
className="w-full h-full object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<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}
|
||||
alt={album.name}
|
||||
@@ -78,7 +151,7 @@ export function AlbumArtwork({
|
||||
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={handleStar}>
|
||||
@@ -122,12 +195,6 @@ export function AlbumArtwork({
|
||||
<ContextMenuItem>Share</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<div className="space-y-1 text-sm" >
|
||||
<p className="font-medium leading-none" onClick={handleClick}>{album.name}</p>
|
||||
<p className="text-xs text-muted-foreground underline">
|
||||
<Link href={`/artist/${album.artistId}`}>{album.artist}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import Image from "next/image"
|
||||
import { PlusCircledIcon } from "@radix-ui/react-icons"
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -15,20 +14,23 @@ import {
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "../../components/ui/context-menu"
|
||||
|
||||
import { Artist } from "@/lib/navidrome"
|
||||
import { useNavidrome } from "./NavidromeContext"
|
||||
import { useAudioPlayer } from "@/app/components/AudioPlayerContext";
|
||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { Artist } from '@/lib/navidrome';
|
||||
|
||||
interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
artist: Artist
|
||||
size?: number
|
||||
imageOnly?: boolean
|
||||
}
|
||||
|
||||
export function ArtistIcon({
|
||||
artist,
|
||||
size = 150,
|
||||
imageOnly = false,
|
||||
className,
|
||||
...props
|
||||
}: ArtistIconProps) {
|
||||
@@ -57,11 +59,53 @@ export function ArtistIcon({
|
||||
? api.getCoverArtUrl(artist.coverArt, 200)
|
||||
: '/default-user.jpg';
|
||||
|
||||
// If imageOnly is true, return just the image without context menu or text
|
||||
if (imageOnly) {
|
||||
return (
|
||||
<div
|
||||
className={cn("overflow-hidden rounded-full cursor-pointer flex-shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
style={{ width: size, height: size }}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src={artistImageUrl}
|
||||
alt={artist.name}
|
||||
width={size}
|
||||
height={size}
|
||||
className="w-full h-full object-cover transition-all hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)} {...props}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
<Card key={artist.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
|
||||
<div
|
||||
className="aspect-square relative group"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<div className="w-full h-full">
|
||||
<Image
|
||||
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||
alt={artist.name}
|
||||
width={size}
|
||||
height={size}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.albumCount} albums
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* <div
|
||||
className="overflow-hidden rounded-full cursor-pointer flex-shrink-0"
|
||||
onClick={handleClick}
|
||||
style={{ width: size, height: size }}
|
||||
@@ -73,7 +117,7 @@ export function ArtistIcon({
|
||||
height={size}
|
||||
className="w-full h-full object-cover transition-all hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={handleStar}>
|
||||
@@ -117,10 +161,6 @@ export function ArtistIcon({
|
||||
<ContextMenuItem>Share</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<div className="space-y-1 text-sm" onClick={handleClick}>
|
||||
<p className="font-medium leading-none text-center">{artist.name}</p>
|
||||
<p className="text-xs text-muted-foreground text-center">{artist.albumCount} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu } from "@/app/components/menu";
|
||||
import { Sidebar } from "@/app/components/sidebar";
|
||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||
@@ -15,13 +15,73 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
||||
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
|
||||
const [isStatusBarVisible, setIsStatusBarVisible] = useState(true);
|
||||
const [isSidebarHidden, setIsSidebarHidden] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const { playlists } = useNavidrome();
|
||||
|
||||
// Handle client-side hydration
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
const savedCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
setIsSidebarCollapsed(savedCollapsed);
|
||||
}, []);
|
||||
|
||||
const toggleSidebarCollapse = () => {
|
||||
const newCollapsed = !isSidebarCollapsed;
|
||||
setIsSidebarCollapsed(newCollapsed);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sidebar-collapsed', newCollapsed.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
if (!isSidebarVisible) {
|
||||
setIsSidebarHidden(true); // This will fully hide the sidebar after transition
|
||||
}
|
||||
};
|
||||
|
||||
if (!isClient) {
|
||||
// Return a basic layout during SSR to match initial client render
|
||||
return (
|
||||
<div className="hidden md:flex md:flex-col md:h-screen">
|
||||
{/* Top Menu */}
|
||||
<div
|
||||
className="sticky z-10 bg-background border-b"
|
||||
style={{
|
||||
left: 'env(titlebar-area-x, 0)',
|
||||
top: 'env(titlebar-area-y, 0)',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
toggleSidebar={() => setIsSidebarVisible(!isSidebarVisible)}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="w-64 flex-shrink-0 border-r transition-all duration-200">
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
collapsed={false}
|
||||
onToggle={toggleSidebarCollapse}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Audio Player */}
|
||||
<AudioPlayer />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="hidden md:flex md:flex-col md:h-screen">
|
||||
{/* Top Menu */}
|
||||
@@ -43,10 +103,12 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-64 flex-shrink-0 border-r">
|
||||
<div className={`${isSidebarCollapsed ? 'w-16' : 'w-64'} flex-shrink-0 border-r transition-all duration-200`}>
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggle={toggleSidebarCollapse}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,31 @@ import React from 'react';
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12 mb-4"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg
|
||||
className="animate-spin h-12 w-12 text-primary/50 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-white text-lg font-medium">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isConnected } = useNavidrome();
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
|
||||
|
||||
// For this demo, we'll show connection status instead of user auth
|
||||
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
||||
@@ -57,6 +59,29 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
setIsFullScreen(!isFullScreen)
|
||||
}, [isFullScreen])
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Get Navidrome URL from localStorage
|
||||
const config = localStorage.getItem("navidrome-config");
|
||||
if (config) {
|
||||
try {
|
||||
const { serverUrl } = JSON.parse(config);
|
||||
if (serverUrl) {
|
||||
// Remove protocol (http:// or https://) and trailing slash
|
||||
const prettyUrl = serverUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
setNavidromeUrl(prettyUrl);
|
||||
} else {
|
||||
setNavidromeUrl(null);
|
||||
}
|
||||
} catch {
|
||||
setNavidromeUrl(null);
|
||||
}
|
||||
} else {
|
||||
setNavidromeUrl(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
||||
@@ -73,23 +98,20 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
if (isClient) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (isClient) {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
};
|
||||
}, [router, toggleSidebar, handleFullScreen]);
|
||||
}, [router, toggleSidebar, handleFullScreen, isClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 mx-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-400"}`}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginRight: '0.24rem' }} className="border-r-4 w-0"><p className="invisible">j</p></div>
|
||||
|
||||
<div className="flex items-center justify-between w-full ml-2">
|
||||
<Menubar
|
||||
className="rounded-none border-b border-none px-0 lg:px-0 flex-1"
|
||||
style={{
|
||||
@@ -107,7 +129,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => window.close()}>
|
||||
<MenubarItem onClick={() => isClient && window.close()}>
|
||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
@@ -288,30 +310,21 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Navidrome URL</span>
|
||||
<span className="text-xs truncate max-w-[160px] text-right">
|
||||
{typeof window !== "undefined"
|
||||
? (() => {
|
||||
const config = localStorage.getItem("navidrome-config");
|
||||
if (config) {
|
||||
try {
|
||||
const { serverUrl } = JSON.parse(config);
|
||||
if (serverUrl) {
|
||||
// Remove protocol (http:// or https://) and trailing slash
|
||||
const prettyUrl = serverUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
return prettyUrl;
|
||||
}
|
||||
return <span className="italic text-gray-400">Not set</span>;
|
||||
} catch {
|
||||
return <span className="italic text-gray-400">Invalid config</span>;
|
||||
}
|
||||
}
|
||||
return <span className="italic text-gray-400">Not set</span>;
|
||||
})()
|
||||
: <span className="italic text-gray-400">Not available</span>}
|
||||
{!isClient ? (
|
||||
<span className="italic text-gray-400">Loading...</span>
|
||||
) : navidromeUrl ? (
|
||||
navidromeUrl
|
||||
) : (
|
||||
<span className="italic text-gray-400">Not set</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-col items-center gap-1 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Commit: {process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Copyright © {new Date().getFullYear()} <a
|
||||
href="https://github.com/sillyangel"
|
||||
target="_blank"
|
||||
|
||||
@@ -7,32 +7,66 @@ import { Button } from "../../components/ui/button";
|
||||
import { ScrollArea } from "../../components/ui/scroll-area";
|
||||
import Link from "next/link";
|
||||
import { Playlist } from "@/lib/navidrome";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
playlists: Playlist[];
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
const isRoot = usePathname() === "/";
|
||||
const isBrowse = usePathname() === "/browse";
|
||||
const isSearch = usePathname() === "/search";
|
||||
const isAlbums = usePathname() === "/library/albums";
|
||||
const isArtists = usePathname() === "/library/artists";
|
||||
const isQueue = usePathname() === "/queue";
|
||||
const isRadio = usePathname() === "/radio";
|
||||
const isHistory = usePathname() === "/history";
|
||||
const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists";
|
||||
export function Sidebar({ className, playlists, collapsed = false, onToggle }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Define all routes and their active states
|
||||
const routes = {
|
||||
isRoot: pathname === "/",
|
||||
isBrowse: pathname === "/browse",
|
||||
isSearch: pathname === "/search",
|
||||
isQueue: pathname === "/queue",
|
||||
isRadio: pathname === "/radio",
|
||||
isPlaylists: pathname === "/library/playlists",
|
||||
isSongs: pathname === "/library/songs",
|
||||
isArtists: pathname === "/library/artists",
|
||||
isAlbums: pathname === "/library/albums",
|
||||
isHistory: pathname === "/history",
|
||||
isFavorites: pathname === "/favorites",
|
||||
isSettings: pathname === "/settings",
|
||||
// Handle dynamic routes
|
||||
isAlbumPage: pathname.startsWith("/album/"),
|
||||
isArtistPage: pathname.startsWith("/artist/"),
|
||||
isPlaylistPage: pathname.startsWith("/playlist/"),
|
||||
isNewPage: pathname === "/new",
|
||||
};
|
||||
|
||||
// Helper function to determine if any sidebar route is active
|
||||
// This prevents highlights on pages not defined in sidebar
|
||||
const isAnySidebarRouteActive = Object.values(routes).some(Boolean);
|
||||
|
||||
return (
|
||||
<div className={cn("pb-6", className)}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className={cn("pb-23 relative", className)}>
|
||||
{/* Collapse/Expand Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="absolute top-2 right-2 z-10 h-6 w-6 p-0"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 py-4 pt-6">
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||
Discover
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Link href="/">
|
||||
<Button variant={isRoot ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isRoot ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Listen Now" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -41,16 +75,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
Listen Now
|
||||
{!collapsed && "Listen Now"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/browse">
|
||||
<Button variant={isBrowse ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isBrowse ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Browse" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -59,18 +97,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
Browse
|
||||
{!collapsed && "Browse"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/search">
|
||||
<Button variant={isSearch ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isSearch ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Search" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -79,16 +121,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
Search
|
||||
{!collapsed && "Search"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/queue">
|
||||
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isQueue ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Queue" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -97,15 +143,19 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
Queue
|
||||
{!collapsed && "Queue"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/radio">
|
||||
<Button variant={isRadio ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isRadio ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Radio" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -114,7 +164,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||
@@ -122,19 +172,23 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
|
||||
</svg>
|
||||
Radio
|
||||
{!collapsed && "Radio"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
<div className="px-3 py-0 pt-0">
|
||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||
Library
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Link href="/library/playlists">
|
||||
<Button variant={isPlaylists ? "secondary" : "ghost"} className="w-full justify-start mb-1">
|
||||
<Button
|
||||
variant={routes.isPlaylists ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-1", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Playlists" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -143,7 +197,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M21 15V6" />
|
||||
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
||||
@@ -151,29 +205,59 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
<path d="M16 6H3" />
|
||||
<path d="M12 18H3" />
|
||||
</svg>
|
||||
Playlists
|
||||
{!collapsed && "Playlists"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/songs">
|
||||
<Button variant={isSongs ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Button
|
||||
variant={routes.isSongs ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Songs" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="18" r="4" />
|
||||
<path d="M12 18V2l7 4" />
|
||||
</svg>
|
||||
Songs
|
||||
{!collapsed && "Songs"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/artists">
|
||||
<Button variant={isArtists ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
||||
<Button
|
||||
variant={routes.isArtists ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Artists" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
|
||||
<circle cx="17" cy="7" r="5" />
|
||||
</svg>
|
||||
Artists
|
||||
{!collapsed && "Artists"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/albums">
|
||||
<Button variant={isAlbums ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isAlbums ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Albums" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -182,18 +266,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="m16 6 4 14" />
|
||||
<path d="M12 6v14" />
|
||||
<path d="M8 8v12" />
|
||||
<path d="M4 4v16" />
|
||||
</svg>
|
||||
Albums
|
||||
{!collapsed && "Albums"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/history">
|
||||
<Button variant={isHistory ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={routes.isHistory ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "History" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -202,17 +290,64 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
|
||||
<path d="M12 8v4l4 2" />
|
||||
</svg>
|
||||
History
|
||||
{!collapsed && "History"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/favorites">
|
||||
<Button
|
||||
variant={routes.isFavorites ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Favorites" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
{!collapsed && "Favorites"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="space-y-0">
|
||||
<Link href="/settings">
|
||||
<Button
|
||||
variant={routes.isSettings ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Settings" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{!collapsed && "Settings"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
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,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const [step, setStep] = useState<'login' | 'settings'>('login');
|
||||
const [canSkipNavidrome, setCanSkipNavidrome] = useState(false);
|
||||
const { config, updateConfig, testConnection } = useNavidromeConfig();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
@@ -43,6 +45,85 @@ export function LoginForm({
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
return !!(process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD);
|
||||
}, []);
|
||||
|
||||
// Check if Navidrome is already working on component mount
|
||||
const checkNavidromeConnection = useCallback(async () => {
|
||||
try {
|
||||
// First check if there's a working API instance
|
||||
const { getNavidromeAPI } = await import('@/lib/navidrome');
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
if (api) {
|
||||
// Test the existing API
|
||||
const success = await api.ping();
|
||||
if (success) {
|
||||
setCanSkipNavidrome(true);
|
||||
|
||||
// Get the current config to populate form
|
||||
if (config.serverUrl && config.username && config.password) {
|
||||
setFormData({
|
||||
serverUrl: config.serverUrl,
|
||||
username: config.username,
|
||||
password: config.password
|
||||
});
|
||||
}
|
||||
|
||||
// If this is first-time setup and Navidrome is working, skip to settings
|
||||
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||
if (!hasCompletedOnboarding) {
|
||||
setStep('settings');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If no working API, check if we have config that just needs testing
|
||||
if (config.serverUrl && config.username && config.password) {
|
||||
const success = await testConnection(config);
|
||||
if (success) {
|
||||
setCanSkipNavidrome(true);
|
||||
setFormData({
|
||||
serverUrl: config.serverUrl,
|
||||
username: config.username,
|
||||
password: config.password
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||
if (!hasCompletedOnboarding) {
|
||||
setStep('settings');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Navidrome connection check failed, will show config step');
|
||||
}
|
||||
}, [config, setStep, setFormData, setCanSkipNavidrome, testConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
checkNavidromeConnection();
|
||||
}, [checkNavidromeConnection]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@@ -104,8 +185,13 @@ export function LoginForm({
|
||||
};
|
||||
|
||||
const handleFinishSetup = () => {
|
||||
// Save scrobbling preference
|
||||
// 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');
|
||||
|
||||
toast({
|
||||
title: "Setup Complete",
|
||||
@@ -126,7 +212,9 @@ export function LoginForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaPalette className="w-5 h-5" />
|
||||
Customize Your Experience
|
||||
{canSkipNavidrome && <Badge variant="outline">Step 1 of 1</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your preferences to get started
|
||||
@@ -155,6 +243,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">
|
||||
@@ -180,18 +291,45 @@ 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" />
|
||||
Complete Setup
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setStep('login')}
|
||||
>
|
||||
Back to Connection Settings
|
||||
</Button>
|
||||
{!hasEnvConfig && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setStep('login')}
|
||||
>
|
||||
{canSkipNavidrome ? "Review Connection Settings" : "Back to Connection Settings"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -205,10 +343,17 @@ export function LoginForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
Connect to Navidrome
|
||||
{canSkipNavidrome && <Badge variant="outline">{hasEnvConfig ? "Configured via .env" : "Already Connected"}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Navidrome server details to get started
|
||||
{canSkipNavidrome
|
||||
? hasEnvConfig
|
||||
? "Your Navidrome connection is configured via environment variables."
|
||||
: "Your Navidrome connection is working. You can proceed to customize your settings."
|
||||
: "Enter your Navidrome server details to get started"
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -269,6 +414,17 @@ export function LoginForm({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{canSkipNavidrome && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setStep('settings')}
|
||||
>
|
||||
Skip to Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
301
app/favorites/page.tsx
Normal file
301
app/favorites/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||
import { AlbumArtwork } from "@/app/components/album-artwork";
|
||||
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
|
||||
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||
import Image from "next/image";
|
||||
|
||||
const FavoritesPage = () => {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const { playTrack, addToQueue } = useAudioPlayer();
|
||||
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||
const [favoriteSongs, setFavoriteSongs] = useState<Song[]>([]);
|
||||
const [favoriteArtists, setFavoriteArtists] = useState<Artist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFavorites = async () => {
|
||||
if (!api || !isConnected) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [albums, songs, artists] = await Promise.all([
|
||||
api.getAlbums('starred', 100),
|
||||
api.getStarred2(),
|
||||
api.getArtists()
|
||||
]);
|
||||
|
||||
setFavoriteAlbums(albums);
|
||||
|
||||
// Filter starred songs and artists from the starred2 response
|
||||
if (songs.starred2) {
|
||||
setFavoriteSongs(songs.starred2.song || []);
|
||||
setFavoriteArtists((songs.starred2.artist || []).filter((artist: Artist) => artist.starred));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load favorites:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFavorites();
|
||||
}, [api, isConnected]);
|
||||
|
||||
const handlePlaySong = (song: Song) => {
|
||||
playTrack({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const songs = await api.getAlbumSongs(album.id);
|
||||
if (songs.length > 0) {
|
||||
const tracks = songs.map((song: Song) => ({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
playTrack(tracks[0]);
|
||||
tracks.slice(1).forEach((track: Track) => addToQueue(track));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = async (id: string, type: 'song' | 'album' | 'artist', isStarred: boolean) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
if (isStarred) {
|
||||
await api.unstar(id, type);
|
||||
} else {
|
||||
await api.star(id, type);
|
||||
}
|
||||
|
||||
// Refresh favorites
|
||||
if (type === 'album') {
|
||||
const albums = await api.getAlbums('starred', 100);
|
||||
setFavoriteAlbums(albums);
|
||||
} else if (type === 'song') {
|
||||
const songs = await api.getStarred2();
|
||||
setFavoriteSongs(songs.starred2?.song || []);
|
||||
} else if (type === 'artist') {
|
||||
const songs = await api.getStarred2();
|
||||
setFavoriteArtists((songs.starred2?.artist || []).filter((artist: Artist) => artist.starred));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">Please connect to your Navidrome server to view favorites.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 pb-24">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
|
||||
<p className="text-muted-foreground">Your starred albums, songs, and artists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="albums" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="albums" className="flex items-center gap-2">
|
||||
<Disc className="w-4 h-4" />
|
||||
Albums ({favoriteAlbums.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="songs" className="flex items-center gap-2">
|
||||
<Music className="w-4 h-4" />
|
||||
Songs ({favoriteSongs.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="artists" className="flex items-center gap-2">
|
||||
<Mic className="w-4 h-4" />
|
||||
Artists ({favoriteArtists.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="albums" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite albums...</p>
|
||||
</div>
|
||||
) : favoriteAlbums.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite albums yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star albums to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{favoriteAlbums.map((album) => (
|
||||
<Card key={album.id} className="overflow-hidden">
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt)}
|
||||
alt={album.name}
|
||||
fill
|
||||
className="w-full h-full object-cover rounded"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
/>
|
||||
) : (
|
||||
<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-12 h-12 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">{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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="songs" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite songs...</p>
|
||||
</div>
|
||||
) : favoriteSongs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite songs yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star songs to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{favoriteSongs.map((song, index) => (
|
||||
<div key={song.id} className="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 group">
|
||||
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="w-12 h-12 relative flex-shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt)}
|
||||
alt={song.album}
|
||||
fill
|
||||
className="rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded 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>
|
||||
<p className="text-sm text-muted-foreground truncate">{song.artist}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{song.album}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="ghost" onClick={() => handlePlaySong(song)}>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleFavorite(song.id, 'song', !!song.starred)}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${song.starred ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="artists" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite artists...</p>
|
||||
</div>
|
||||
) : favoriteArtists.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite artists yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star artists to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7">
|
||||
{favoriteArtists.map((artist) => (
|
||||
<Card key={artist.id} className="overflow-hidden">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-4">
|
||||
<Image
|
||||
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||
alt={artist.name}
|
||||
width={250}
|
||||
height={250}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.albumCount} albums
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesPage;
|
||||
@@ -15,6 +15,10 @@ body {
|
||||
.animate-scroll {
|
||||
animation: scroll 8s linear infinite;
|
||||
}
|
||||
|
||||
.animate-infinite-scroll {
|
||||
animation: infiniteScroll 10s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
@@ -26,6 +30,15 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes infiniteScroll {
|
||||
0% {
|
||||
transform: translateX(15%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-215%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
@@ -284,9 +297,10 @@ body {
|
||||
|
||||
|
||||
|
||||
:focus-visible { outline-color: var(rgb(59 130 246)); }
|
||||
::selection { background-color: var(rgb(59 130 246)); }
|
||||
::marker { color: var(rgb(59 130 246)); }
|
||||
:focus-visible { outline-color: rgb(59, 130, 246); }
|
||||
::selection { background-color: rgb(59, 130, 246); }
|
||||
::marker { color: rgb(59, 130, 246); }
|
||||
|
||||
|
||||
|
||||
::selection {
|
||||
|
||||
@@ -7,17 +7,34 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { Artist } from '@/lib/navidrome';
|
||||
import Loading from '@/app/components/loading';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, Heart } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ArtistPage() {
|
||||
const { artists, isLoading } = useNavidrome();
|
||||
const { artists, isLoading, api, starItem, unstarItem } = useNavidrome();
|
||||
const [filteredArtists, setFilteredArtists] = useState<Artist[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'albumCount'>('name');
|
||||
const router = useRouter();
|
||||
|
||||
const toggleFavorite = async (artistId: string, isStarred: boolean) => {
|
||||
if (isStarred) {
|
||||
await unstarItem(artistId, 'artist');
|
||||
} else {
|
||||
await starItem(artistId, 'artist');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewArtist = (artist: Artist) => {
|
||||
router.push(`/artist/${artist.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (artists.length > 0) {
|
||||
@@ -87,14 +104,29 @@ export default function ArtistPage() {
|
||||
<Separator className="my-4" />
|
||||
<div className="relative">
|
||||
<ScrollArea>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 cursor-pointer">
|
||||
{filteredArtists.map((artist) => (
|
||||
<ArtistIcon
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
className="flex justify-center"
|
||||
size={150}
|
||||
/>
|
||||
<Card key={artist.id} className="overflow-hidden">
|
||||
<div className="aspect-square relative group cursor-pointer" onClick={() => handleViewArtist(artist)}>
|
||||
<div className="w-full h-full">
|
||||
<Image
|
||||
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||
alt={artist.name}
|
||||
width={290}
|
||||
height={290}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.albumCount} albums
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
|
||||
@@ -116,7 +116,8 @@ export default function SongsPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
playTrack(track);
|
||||
@@ -136,7 +137,8 @@ export default function SongsPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
addToQueue(track);
|
||||
|
||||
66
app/page.tsx
66
app/page.tsx
@@ -11,9 +11,11 @@ import { useNavidromeConfig } from './components/NavidromeConfigContext';
|
||||
|
||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||
export default function MusicPage() {
|
||||
const { albums, isLoading } = useNavidrome();
|
||||
const { albums, isLoading, api, isConnected } = useNavidrome();
|
||||
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
|
||||
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
|
||||
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||
const [favoritesLoading, setFavoritesLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (albums.length > 0) {
|
||||
@@ -25,6 +27,24 @@ export default function MusicPage() {
|
||||
}
|
||||
}, [albums]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFavoriteAlbums = async () => {
|
||||
if (!api || !isConnected) return;
|
||||
|
||||
setFavoritesLoading(true);
|
||||
try {
|
||||
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
|
||||
setFavoriteAlbums(starredAlbums);
|
||||
} catch (error) {
|
||||
console.error('Failed to load favorite albums:', error);
|
||||
} finally {
|
||||
setFavoritesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFavoriteAlbums();
|
||||
}, [api, isConnected]);
|
||||
|
||||
// Get greeting and time of day
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
|
||||
@@ -88,7 +108,7 @@ export default function MusicPage() {
|
||||
{isLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="w-[220px] h-[220px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||
))
|
||||
) : (
|
||||
recentAlbums.map((album) => (
|
||||
@@ -106,6 +126,46 @@ export default function MusicPage() {
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Favorite Albums Section */}
|
||||
{favoriteAlbums.length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 space-y-1">
|
||||
<p className="text-2xl font-semibold tracking-tight">
|
||||
Favorite Albums
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your starred albums collection.
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="relative">
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{favoritesLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||
))
|
||||
) : (
|
||||
favoriteAlbums.map((album) => (
|
||||
<AlbumArtwork
|
||||
key={album.id}
|
||||
album={album}
|
||||
className="w-[220px] flex-shrink-0"
|
||||
aspectRatio="square"
|
||||
width={220}
|
||||
height={220}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-1">
|
||||
<p className="text-2xl font-semibold tracking-tight">
|
||||
Your Library
|
||||
@@ -121,7 +181,7 @@ export default function MusicPage() {
|
||||
{isLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="w-[220px] h-[200px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||
))
|
||||
) : (
|
||||
newestAlbums.map((album) => (
|
||||
|
||||
@@ -59,7 +59,8 @@ export default function PlaylistPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
playTrack(track);
|
||||
};
|
||||
@@ -78,7 +79,8 @@ export default function PlaylistPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
addToQueue(track);
|
||||
};
|
||||
@@ -98,7 +100,8 @@ export default function PlaylistPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
// Play the first track and add the rest to queue
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, User, Disc, Trash2, SkipForward } from 'lucide-react';
|
||||
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
|
||||
|
||||
const QueuePage: React.FC = () => {
|
||||
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
|
||||
@@ -46,7 +46,7 @@ const QueuePage: React.FC = () => {
|
||||
{currentTrack && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Now Playing</h2>
|
||||
<div className="p-4 bg-accent/30 rounded-lg border-l-4 border-primary">
|
||||
<div className="p-4 bg-accent/30 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
{/* Album Art */}
|
||||
<div className="w-16 h-16 mr-4 flex-shrink-0">
|
||||
@@ -65,21 +65,13 @@ const QueuePage: React.FC = () => {
|
||||
<p className="font-semibold text-lg text-primary truncate">
|
||||
{currentTrack.name}
|
||||
</p>
|
||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<Link href={`/artist/${currentTrack.artistId}`} className="truncate hover:text-primary hover:underline">
|
||||
{currentTrack.artist}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Disc className="w-3 h-3" />
|
||||
<Link href={`/album/${currentTrack.albumId}`} className="truncate hover:text-primary hover:underline">
|
||||
{currentTrack.album}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,14 +114,8 @@ const QueuePage: React.FC = () => {
|
||||
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => skipToTrackInQueue(index)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
<span className="group-hover:hidden">{index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</div>
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="w-12 h-12 mr-4 flex-shrink-0">
|
||||
{/* Album Art with Play Indicator */}
|
||||
<div className="w-12 h-12 mr-4 flex-shrink-0 relative">
|
||||
<Image
|
||||
src={track.coverArt || '/default-user.jpg'}
|
||||
alt={track.album}
|
||||
@@ -137,6 +123,9 @@ const QueuePage: React.FC = () => {
|
||||
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 */}
|
||||
@@ -146,7 +135,6 @@ const QueuePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<Link
|
||||
href={`/artist/${track.artistId}`}
|
||||
className="truncate hover:text-primary hover:underline"
|
||||
@@ -155,16 +143,6 @@ const QueuePage: React.FC = () => {
|
||||
{track.artist}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Disc className="w-3 h-3" />
|
||||
<Link
|
||||
href={`/album/${track.albumId}`}
|
||||
className="truncate hover:text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{track.album}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ export default function SearchPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
playTrack(track);
|
||||
@@ -86,7 +87,8 @@ export default function SearchPage() {
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
addToQueue(track);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
@@ -9,12 +9,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm } from 'react-icons/fa';
|
||||
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
||||
import { Settings, ExternalLink } from 'lucide-react';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { config, updateConfig, isConnected, testConnection, clearConfig } = useNavidromeConfig();
|
||||
const { toast } = useToast();
|
||||
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serverUrl: config.serverUrl,
|
||||
@@ -24,7 +27,7 @@ const SettingsPage = () => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Last.fm scrobbling settings
|
||||
// Last.fm scrobbling settings (Navidrome integration)
|
||||
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('lastfm-scrobbling-enabled') === 'true';
|
||||
@@ -32,6 +35,49 @@ const SettingsPage = () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Standalone Last.fm settings
|
||||
const [standaloneLastFmEnabled, setStandaloneLastFmEnabled] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const [lastFmCredentials, setLastFmCredentials] = useState({
|
||||
apiKey: '',
|
||||
apiSecret: '',
|
||||
sessionKey: '',
|
||||
username: ''
|
||||
});
|
||||
|
||||
// Check if Navidrome is configured via environment variables
|
||||
const hasEnvConfig = React.useMemo(() => {
|
||||
return !!(process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD);
|
||||
}, []);
|
||||
|
||||
// Sidebar settings
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Load Last.fm credentials on mount
|
||||
useEffect(() => {
|
||||
const credentials = getCredentials();
|
||||
if (credentials) {
|
||||
setLastFmCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
apiSecret: credentials.apiSecret,
|
||||
sessionKey: credentials.sessionKey || '',
|
||||
username: credentials.username || ''
|
||||
});
|
||||
}
|
||||
}, [getCredentials]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasUnsavedChanges(true);
|
||||
@@ -134,24 +180,116 @@ const SettingsPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStandaloneLastFmToggle = (enabled: boolean) => {
|
||||
setStandaloneLastFmEnabled(enabled);
|
||||
localStorage.setItem('standalone-lastfm-enabled', enabled.toString());
|
||||
toast({
|
||||
title: enabled ? "Standalone Last.fm Enabled" : "Standalone Last.fm Disabled",
|
||||
description: enabled
|
||||
? "Direct Last.fm integration enabled"
|
||||
: "Standalone Last.fm integration disabled",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSidebarToggle = (collapsed: boolean) => {
|
||||
setSidebarCollapsed(collapsed);
|
||||
localStorage.setItem('sidebar-collapsed', collapsed.toString());
|
||||
toast({
|
||||
title: collapsed ? "Sidebar Collapsed" : "Sidebar Expanded",
|
||||
description: collapsed
|
||||
? "Sidebar will show only icons"
|
||||
: "Sidebar will show full labels",
|
||||
});
|
||||
|
||||
// Trigger a custom event to notify the sidebar component
|
||||
window.dispatchEvent(new CustomEvent('sidebar-toggle', { detail: { collapsed } }));
|
||||
};
|
||||
|
||||
const handleLastFmAuth = () => {
|
||||
if (!lastFmCredentials.apiKey) {
|
||||
toast({
|
||||
title: "API Key Required",
|
||||
description: "Please enter your Last.fm API key first.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl(lastFmCredentials.apiKey);
|
||||
window.open(authUrl, '_blank');
|
||||
|
||||
toast({
|
||||
title: "Last.fm Authorization",
|
||||
description: "Please authorize the application in the opened window and return here.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleLastFmCredentialsSave = () => {
|
||||
if (!lastFmCredentials.apiKey || !lastFmCredentials.apiSecret) {
|
||||
toast({
|
||||
title: "Missing Credentials",
|
||||
description: "Please enter both API key and secret.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('lastfm-credentials', JSON.stringify(lastFmCredentials));
|
||||
toast({
|
||||
title: "Credentials Saved",
|
||||
description: "Last.fm credentials have been saved locally.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleLastFmSessionComplete = async (token: string) => {
|
||||
try {
|
||||
const { sessionKey, username } = await getSessionKey(
|
||||
token,
|
||||
lastFmCredentials.apiKey,
|
||||
lastFmCredentials.apiSecret
|
||||
);
|
||||
|
||||
const updatedCredentials = {
|
||||
...lastFmCredentials,
|
||||
sessionKey,
|
||||
username
|
||||
};
|
||||
|
||||
setLastFmCredentials(updatedCredentials);
|
||||
localStorage.setItem('lastfm-credentials', JSON.stringify(updatedCredentials));
|
||||
|
||||
toast({
|
||||
title: "Last.fm Authentication Complete",
|
||||
description: `Successfully authenticated as ${username}`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Authentication Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to complete Last.fm authentication",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-2xl">
|
||||
<div className="container mx-auto p-6 pb-24 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">Customize your music experience</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
Navidrome Server
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure connection to your Navidrome music server
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!hasEnvConfig && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
Navidrome Server
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure connection to your Navidrome music server
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">Server URL</Label>
|
||||
@@ -228,6 +366,35 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasEnvConfig && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
Navidrome Server
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Using environment variables configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<FaCheck className="w-4 h-4 text-green-600" />
|
||||
<div className="text-sm">
|
||||
<p className="text-green-600 font-medium">Configured via Environment Variables</p>
|
||||
<p className="text-green-600">Server: {process.env.NEXT_PUBLIC_NAVIDROME_URL}</p>
|
||||
<p className="text-green-600">Username: {process.env.NEXT_PUBLIC_NAVIDROME_USERNAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your Navidrome connection is configured through environment variables.
|
||||
Contact your administrator to change these settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -276,6 +443,167 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaCog className="w-5 h-5" />
|
||||
Application Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
General application preferences and setup
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label>First-Time Setup</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('onboarding-completed');
|
||||
window.location.reload();
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Run Setup Wizard Again
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Re-run the initial setup wizard to configure your preferences from scratch
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Sidebar Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize sidebar appearance and behavior
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sidebar-mode">Sidebar Mode</Label>
|
||||
<Select
|
||||
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||
onValueChange={(value) => handleSidebarToggle(value === "collapsed")}
|
||||
>
|
||||
<SelectTrigger id="sidebar-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>Expanded:</strong> Shows full navigation labels</p>
|
||||
<p><strong>Collapsed:</strong> Shows only icons with tooltips</p>
|
||||
<p className="mt-3"><strong>Note:</strong> You can also toggle the sidebar using the collapse button in the sidebar.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
Standalone Last.fm Integration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Direct Last.fm scrobbling without Navidrome configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="standalone-lastfm-enabled">Enable Standalone Last.fm</Label>
|
||||
<Select
|
||||
value={standaloneLastFmEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => handleStandaloneLastFmToggle(value === "enabled")}
|
||||
>
|
||||
<SelectTrigger id="standalone-lastfm-enabled">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{standaloneLastFmEnabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastfm-api-key">Last.fm API Key</Label>
|
||||
<Input
|
||||
id="lastfm-api-key"
|
||||
type="text"
|
||||
placeholder="Your Last.fm API key"
|
||||
value={lastFmCredentials.apiKey}
|
||||
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastfm-api-secret">Last.fm API Secret</Label>
|
||||
<Input
|
||||
id="lastfm-api-secret"
|
||||
type="password"
|
||||
placeholder="Your Last.fm API secret"
|
||||
value={lastFmCredentials.apiSecret}
|
||||
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastFmCredentials.sessionKey ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<FaCheck className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">
|
||||
Authenticated as {lastFmCredentials.username}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<FaTimes className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-600">Not authenticated</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleLastFmCredentialsSave} variant="outline">
|
||||
Save Credentials
|
||||
</Button>
|
||||
<Button onClick={handleLastFmAuth} disabled={!lastFmCredentials.apiKey || !lastFmCredentials.apiSecret}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Authorize with Last.fm
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>Setup Instructions:</strong></p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>Create a Last.fm API account at <a href="https://www.last.fm/api" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">last.fm/api</a></li>
|
||||
<li>Enter your API key and secret above</li>
|
||||
<li>Save credentials and click "Authorize with Last.fm"</li>
|
||||
<li>Complete the authorization process</li>
|
||||
</ol>
|
||||
<p className="mt-3"><strong>Features:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Direct scrobbling to Last.fm (independent of Navidrome)</li>
|
||||
<li>"Now Playing" updates</li>
|
||||
<li>Follows Last.fm scrobbling rules (30s minimum or 50% played)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
|
||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -44,10 +46,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
|
||||
25
docker-compose.override.yml.example
Normal file
25
docker-compose.override.yml.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Docker Compose Override Example for Local Development
|
||||
# This file shows how to override the default docker-compose.yml for local development
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mice:
|
||||
# Override to build locally instead of using pre-built image
|
||||
build: .
|
||||
image: mice:local
|
||||
|
||||
# Enable Navidrome configuration for development
|
||||
environment:
|
||||
- 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)
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - /app/node_modules
|
||||
# - /app/.next
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mice:
|
||||
image: ghcr.io/sillyangel/mice:latest
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:${PORT:-3000}"
|
||||
environment:
|
||||
# Navidrome Server Configuration (OPTIONAL)
|
||||
# Uncomment and configure these if you want to pre-configure the server connection
|
||||
# If not set, the app will prompt you to configure these settings on first launch
|
||||
# - NEXT_PUBLIC_NAVIDROME_URL=${NAVIDROME_URL:-}
|
||||
# - 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=${PORT:-3000}
|
||||
|
||||
# Optional: Add a health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${PORT:-3000}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
restart: unless-stopped
|
||||
244
hooks/use-standalone-lastfm.ts
Normal file
244
hooks/use-standalone-lastfm.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
interface LastFmCredentials {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
sessionKey?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ScrobbleState {
|
||||
trackId: string | null;
|
||||
hasScrobbled: boolean;
|
||||
hasUpdatedNowPlaying: boolean;
|
||||
playStartTime: number;
|
||||
lastPlayedDuration: number;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
albumName?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function useStandaloneLastFm() {
|
||||
const scrobbleStateRef = useRef<ScrobbleState>({
|
||||
trackId: null,
|
||||
hasScrobbled: false,
|
||||
hasUpdatedNowPlaying: false,
|
||||
playStartTime: 0,
|
||||
lastPlayedDuration: 0,
|
||||
});
|
||||
|
||||
const getCredentials = (): LastFmCredentials | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = localStorage.getItem('lastfm-credentials');
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isEnabled = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const enabled = localStorage.getItem('standalone-lastfm-enabled');
|
||||
const credentials = getCredentials();
|
||||
return enabled === 'true' && credentials?.sessionKey;
|
||||
};
|
||||
|
||||
const generateApiSignature = (params: Record<string, string>, secret: string): string => {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}${params[key]}`)
|
||||
.join('');
|
||||
|
||||
// In a real implementation, you'd use a proper crypto library
|
||||
// For demo purposes, this is a simplified version
|
||||
return btoa(sortedParams + secret).substring(0, 32);
|
||||
};
|
||||
|
||||
const makeLastFmRequest = async (method: string, params: Record<string, string>): Promise<any> => {
|
||||
const credentials = getCredentials();
|
||||
if (!credentials) throw new Error('No Last.fm credentials');
|
||||
|
||||
const requestParams: Record<string, string> = {
|
||||
...params,
|
||||
method,
|
||||
api_key: credentials.apiKey,
|
||||
sk: credentials.sessionKey || '',
|
||||
format: 'json'
|
||||
};
|
||||
|
||||
const signature = generateApiSignature(requestParams, credentials.apiSecret);
|
||||
requestParams.api_sig = signature;
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch('https://ws.audioscrobbler.com/2.0/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const updateNowPlaying = useCallback(async (track: Track) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
try {
|
||||
await makeLastFmRequest('track.updateNowPlaying', {
|
||||
track: track.name,
|
||||
artist: track.artist,
|
||||
album: track.albumName || '',
|
||||
duration: track.duration.toString()
|
||||
});
|
||||
|
||||
scrobbleStateRef.current.hasUpdatedNowPlaying = true;
|
||||
console.log('Updated now playing on Last.fm:', track.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to update now playing on Last.fm:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrobbleTrack = useCallback(async (track: Track, timestamp?: number) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
try {
|
||||
await makeLastFmRequest('track.scrobble', {
|
||||
'track[0]': track.name,
|
||||
'artist[0]': track.artist,
|
||||
'album[0]': track.albumName || '',
|
||||
'timestamp[0]': (timestamp || Math.floor(Date.now() / 1000)).toString()
|
||||
});
|
||||
|
||||
console.log('Scrobbled track to Last.fm:', track.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble track to Last.fm:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const shouldScrobble = (playedDuration: number, totalDuration: number): boolean => {
|
||||
// Last.fm scrobbling rules:
|
||||
// - At least 30 seconds played OR
|
||||
// - At least half the track played (whichever is lower)
|
||||
const minimumTime = Math.min(30, totalDuration / 2);
|
||||
return playedDuration >= minimumTime;
|
||||
};
|
||||
|
||||
const onTrackStart = useCallback(async (track: Track) => {
|
||||
// Reset scrobble state for new track
|
||||
scrobbleStateRef.current = {
|
||||
trackId: track.id,
|
||||
hasScrobbled: false,
|
||||
hasUpdatedNowPlaying: false,
|
||||
playStartTime: Date.now(),
|
||||
lastPlayedDuration: 0,
|
||||
};
|
||||
|
||||
// Update now playing on Last.fm
|
||||
await updateNowPlaying(track);
|
||||
}, [updateNowPlaying]);
|
||||
|
||||
const onTrackPlay = useCallback(async (track: Track) => {
|
||||
scrobbleStateRef.current.playStartTime = Date.now();
|
||||
|
||||
// Update now playing if we haven't already for this track
|
||||
if (!scrobbleStateRef.current.hasUpdatedNowPlaying || scrobbleStateRef.current.trackId !== track.id) {
|
||||
await onTrackStart(track);
|
||||
}
|
||||
}, [onTrackStart]);
|
||||
|
||||
const onTrackPause = useCallback((currentTime: number) => {
|
||||
const now = Date.now();
|
||||
const sessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
scrobbleStateRef.current.lastPlayedDuration += sessionDuration;
|
||||
}, []);
|
||||
|
||||
const onTrackProgress = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||
if (!isEnabled() || scrobbleStateRef.current.hasScrobbled) return;
|
||||
|
||||
// Calculate total played time
|
||||
const now = Date.now();
|
||||
const currentSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + currentSessionDuration;
|
||||
|
||||
// Check if we should scrobble
|
||||
if (shouldScrobble(totalPlayedDuration, duration)) {
|
||||
await scrobbleTrack(track);
|
||||
scrobbleStateRef.current.hasScrobbled = true;
|
||||
}
|
||||
}, [scrobbleTrack]);
|
||||
|
||||
const onTrackEnd = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
// Calculate final played duration
|
||||
const now = Date.now();
|
||||
const finalSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + finalSessionDuration;
|
||||
|
||||
// Scrobble if we haven't already and the track qualifies
|
||||
if (!scrobbleStateRef.current.hasScrobbled && shouldScrobble(totalPlayedDuration, duration)) {
|
||||
await scrobbleTrack(track);
|
||||
}
|
||||
}, [scrobbleTrack]);
|
||||
|
||||
const getAuthUrl = (apiKey: string): string => {
|
||||
return `http://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(window.location.origin + '/settings')}`;
|
||||
};
|
||||
|
||||
const getSessionKey = async (token: string, apiKey: string, apiSecret: string): Promise<{ sessionKey: string; username: string }> => {
|
||||
const params: Record<string, string> = {
|
||||
method: 'auth.getSession',
|
||||
token,
|
||||
api_key: apiKey,
|
||||
format: 'json'
|
||||
};
|
||||
|
||||
const signature = generateApiSignature(params, apiSecret);
|
||||
const url = new URL('https://ws.audioscrobbler.com/2.0/');
|
||||
Object.entries({ ...params, api_sig: signature }).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm auth error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.message || 'Last.fm authentication failed');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionKey: data.session.key,
|
||||
username: data.session.name
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
onTrackStart,
|
||||
onTrackPlay,
|
||||
onTrackPause,
|
||||
onTrackProgress,
|
||||
onTrackEnd,
|
||||
isEnabled,
|
||||
getCredentials,
|
||||
getAuthUrl,
|
||||
getSessionKey
|
||||
};
|
||||
}
|
||||
154
lib/lastfm-api.ts
Normal file
154
lib/lastfm-api.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
interface LastFmCredentials {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
sessionKey?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface LastFmArtistInfo {
|
||||
name: string;
|
||||
bio?: {
|
||||
summary: string;
|
||||
content: string;
|
||||
};
|
||||
stats?: {
|
||||
listeners: string;
|
||||
playcount: string;
|
||||
};
|
||||
similar?: {
|
||||
artist: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
image: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
tags?: {
|
||||
tag: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
image?: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LastFmTopTracks {
|
||||
track: Array<{
|
||||
name: string;
|
||||
playcount: string;
|
||||
listeners: string;
|
||||
artist: {
|
||||
name: string;
|
||||
mbid: string;
|
||||
url: string;
|
||||
};
|
||||
image: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
'@attr': {
|
||||
rank: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LastFmAPI {
|
||||
private baseUrl = 'https://ws.audioscrobbler.com/2.0/';
|
||||
|
||||
private getCredentials(): LastFmCredentials | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = localStorage.getItem('lastfm-credentials');
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(method: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const credentials = this.getCredentials();
|
||||
if (!credentials?.apiKey) {
|
||||
throw new Error('No Last.fm API key available');
|
||||
}
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
url.searchParams.append('method', method);
|
||||
url.searchParams.append('api_key', credentials.apiKey);
|
||||
url.searchParams.append('format', 'json');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.message || 'Last.fm API error');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getArtistInfo(artistName: string): Promise<LastFmArtistInfo | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getInfo', {
|
||||
artist: artistName,
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return (data.artist as LastFmArtistInfo) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist info from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistTopTracks(artistName: string, limit: number = 10): Promise<LastFmTopTracks | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getTopTracks', {
|
||||
artist: artistName,
|
||||
limit: limit.toString(),
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return (data.toptracks as LastFmTopTracks) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist top tracks from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSimilarArtists(artistName: string, limit: number = 6): Promise<LastFmArtistInfo['similar'] | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getSimilar', {
|
||||
artist: artistName,
|
||||
limit: limit.toString(),
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return (data.similarartists as LastFmArtistInfo['similar']) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch similar artists from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
const credentials = this.getCredentials();
|
||||
return !!credentials?.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
export const lastFmAPI = new LastFmAPI();
|
||||
@@ -482,6 +482,47 @@ class NavidromeAPI {
|
||||
});
|
||||
return response.albumInfo2 as AlbumInfo;
|
||||
}
|
||||
|
||||
async getStarred2(): Promise<{ starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } }> {
|
||||
try {
|
||||
const response = await this.makeRequest('getStarred2');
|
||||
return response as { starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } };
|
||||
} catch (error) {
|
||||
console.error('Failed to get starred items:', error);
|
||||
return { starred2: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
try {
|
||||
const response = await this.makeRequest('getAlbum', { id: albumId });
|
||||
const albumData = response.album as { song?: Song[] };
|
||||
return albumData?.song || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get album songs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistTopSongs(artistName: string, limit: number = 10): Promise<Song[]> {
|
||||
try {
|
||||
// Search for songs by the artist and return them sorted by play count
|
||||
const searchResult = await this.search2(artistName, 0, 0, limit * 3);
|
||||
|
||||
// Filter songs that are actually by this artist (exact match)
|
||||
const artistSongs = searchResult.songs.filter(song =>
|
||||
song.artist.toLowerCase() === artistName.toLowerCase()
|
||||
);
|
||||
|
||||
// Sort by play count (descending) and limit results
|
||||
return artistSongs
|
||||
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error('Failed to get artist top songs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance management
|
||||
|
||||
@@ -7,6 +7,7 @@ const nextConfig = {
|
||||
hostname: "**",
|
||||
}
|
||||
],
|
||||
qualities: [ 45, 75, 85, 90, 100 ]
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
@@ -19,8 +20,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"axios": "^1.7.7",
|
||||
@@ -28,11 +28,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"colorthief": "^2.6.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.0.3",
|
||||
"next": "15.3.4",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-node": "^5.1.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.4",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.17",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5"
|
||||
@@ -863,9 +863,9 @@
|
||||
"integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "15.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.4.tgz",
|
||||
"integrity": "sha512-HwlEXwCK3sr6zmVGEvWBjW9tBFs1Oe6hTmTLoFQtpm4As5HCdu8jfSE0XJOp7uhfEGLniIx8yrGxEWwNnY0fmQ==",
|
||||
"version": "15.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.4.tgz",
|
||||
"integrity": "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-glob": "3.3.1"
|
||||
@@ -1051,6 +1051,33 @@
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
|
||||
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -1655,39 +1682,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
|
||||
"integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@@ -3651,12 +3645,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "15.1.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.1.4.tgz",
|
||||
"integrity": "sha512-u9+7lFmfhKNgGjhQ9tBeyCFsPJyq0SvGioMJBngPC7HXUpR0U+ckEwQR48s7TrRNHra1REm6evGL2ie38agALg==",
|
||||
"version": "15.3.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.4.tgz",
|
||||
"integrity": "sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.1.4",
|
||||
"@next/eslint-plugin-next": "15.3.4",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
|
||||
22
package.json
22
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "mice-reworked",
|
||||
"version": "1.0.0",
|
||||
"version": "2025.07.02",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
|
||||
"dev": "next dev -p 40625",
|
||||
"dev": "next dev --turbopack -p 40625",
|
||||
"build": "next build",
|
||||
"start": "next start -p 40625",
|
||||
"lint": "next lint"
|
||||
@@ -30,11 +30,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"colorthief": "^2.6.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.0.3",
|
||||
"next": "15.3.4",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-node": "^5.1.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
@@ -43,14 +43,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.4",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.17",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
|
||||
"overrides": {
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user