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:
2025-07-02 14:17:01 -05:00
committed by GitHub
46 changed files with 3210 additions and 407 deletions

25
.dockerignore Normal file
View 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
View 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

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=e88d8b2
NEXT_PUBLIC_COMMIT_SHA=a854604

View File

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

View File

@@ -1,4 +0,0 @@
{
"IDX.aI.enableInlineCompletion": true,
"IDX.aI.enableCodebaseIndexing": true
}

171
DOCKER.md Normal file
View 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
View 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"]

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => (

View File

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

View File

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

View File

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

View File

@@ -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 &quot;Authorize with Last.fm&quot;</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>&quot;Now Playing&quot; 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
View 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 }

View File

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

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

View 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
View 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();

View File

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

View File

@@ -7,6 +7,7 @@ const nextConfig = {
hostname: "**",
}
],
qualities: [ 45, 75, 85, 90, 100 ]
},
async headers() {
return [

90
package-lock.json generated
View File

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

View File

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