diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e41e0cc --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..19b8e69 --- /dev/null +++ b/.env.docker @@ -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 diff --git a/.env.local b/.env.local index 149077b..26ca566 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=e88d8b2 +NEXT_PUBLIC_COMMIT_SHA=a854604 diff --git a/.idx/dev.nix b/.idx/dev.nix deleted file mode 100644 index 86601d0..0000000 --- a/.idx/dev.nix +++ /dev/null @@ -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"; - }; - }; - }; -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 03adc8d..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "IDX.aI.enableInlineCompletion": true, - "IDX.aI.enableCodebaseIndexing": true -} \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..f81aecf --- /dev/null +++ b/DOCKER.md @@ -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 + +# Docker compose +docker-compose logs -f mice +``` + +### Container Shell Access + +Access the container for debugging: + +```bash +# Docker run +docker exec -it sh + +# Docker compose +docker-compose exec mice sh +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7944a13 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 9f9e726..b87985c 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,14 @@ pnpm install cp .env.example .env ``` -Edit `.env.local` with your Navidrome server details: +Edit `.env` with your Navidrome server details: ```env NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 NEXT_PUBLIC_NAVIDROME_USERNAME=your_username NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password +NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com ``` 3. **Run the development server** @@ -60,6 +62,42 @@ pnpm dev Open [http://localhost:40625](http://localhost:40625) in your browser. +## Docker Deployment + +For easy deployment using Docker: + +### Quick Docker Setup + +```bash +# Run using pre-built image (app will prompt for Navidrome configuration) +docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest + +# Or build locally +docker build -t mice . +docker run -p 3000:3000 mice +``` + +### Docker Compose (Recommended) + +```bash +# Copy environment template and configure +cp .env.docker .env +# Edit .env with your settings (optional - app can prompt) +docker-compose up -d +``` + +### Pre-configured Docker Run + +```bash +docker run -p 3000:3000 \ + -e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \ + -e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \ + -e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \ + ghcr.io/sillyangel/mice:latest +``` + +📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)** + ## Migration from Firebase This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting. diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index e066a77..498579d 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [isStarred, setIsStarred] = useState(false); + const [starredSongs, setStarredSongs] = useState>(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 ; } @@ -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) => (
handlePlayClick(song)} > {/* Track Number / Play Indicator */}
- {isCurrentlyPlaying(song) ? ( -
-
-
- ) : ( <> {song.track || index + 1} - )}
{/* Song Info */} @@ -190,7 +189,6 @@ export default function AlbumPage() {
- {song.artist}
@@ -202,17 +200,20 @@ export default function AlbumPage() {
{/* Actions */} -
+
diff --git a/app/artist/[artist]/page.tsx b/app/artist/[artist]/page.tsx index eaa771b..7432013 100644 --- a/app/artist/[artist]/page.tsx +++ b/app/artist/[artist]/page.tsx @@ -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([]); + const [popularSongs, setPopularSongs] = useState([]); const [loading, setLoading] = useState(true); const [artist, setArtist] = useState(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() { + + {/* About Section */} + + + {/* Popular Songs Section */} + {popularSongs.length > 0 && ( + + )} {/* Albums Section */}
-

Albums

+

Discography

{artistAlbums.map((album) => ( @@ -155,6 +175,12 @@ export default function ArtistPage() {
+ + + + + {/* Similar Artists Section */} +
); diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 3f26460..38d937b 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -119,8 +119,8 @@ export default function BrowsePage() { ))} diff --git a/app/components/ArtistBio.tsx b/app/components/ArtistBio.tsx new file mode 100644 index 0000000..bdfd170 --- /dev/null +++ b/app/components/ArtistBio.tsx @@ -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(''); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState(false); + const [lastFmUrl, setLastFmUrl] = useState(''); + + 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 ( +
+

About

+
+

+ {displayBio} +

+ +
+ {shouldTruncate && ( + + )} + + {lastFmUrl && ( + + )} +
+
+
+ ); +} diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 46eb797..be2ab56 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -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(null); const preloadAudioRef = useRef(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" /> -
+
-

+

{currentTrack.name}

{currentTrack.artist}

+ {/* Heart icon for favoriting */} +
-
- - - -
-
+ + + + +
+ + {/* Progress bar */} + {/*
+ + {formatTime(audioCurrent?.currentTime ?? 0)} + + + + {formatTime(audioCurrent?.duration ?? 0)} + +
*/} +
+ + {/* Right side buttons */} +
@@ -424,16 +506,4 @@ export const AudioPlayer: React.FC = () => { /> ); -}; - - -// {/* Progress bar */} -//
-// -// {formatTime(audioCurrent?.currentTime ?? 0)} -// -// -// -// {formatTime(audioCurrent?.duration ?? 0)} -// -//
\ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index bd60a40..ebeea08 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -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; playedTracks: Track[]; clearHistory: () => void; + toggleCurrentTrackStar: () => Promise; + updateTrackStarred: (trackId: string, starred: boolean) => void; } const AudioPlayerContext = createContext(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 ( diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index cb7fca3..84696da 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -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 = ({ 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 = ({ isOpen, onCl

{currentTrack.name}

- - {currentTrack.artist} + + {currentTrack.artist} + + + {currentTrack.album} @@ -384,17 +396,17 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl - {lyrics.length > 0 && ( - - )} + + + @@ -410,6 +422,17 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl )} + {lyrics.length > 0 && ( + + )} {showVolumeSlider && (
; + // Data albums: Album[]; artists: Artist[]; @@ -387,6 +390,9 @@ export const NavidromeProvider: React.FC = ({ children } }, [api, refreshData]); const value: NavidromeContextType = { + // API instance + api, + // Data albums, artists, diff --git a/app/components/PopularSongs.tsx b/app/components/PopularSongs.tsx new file mode 100644 index 0000000..b4b2eef --- /dev/null +++ b/app/components/PopularSongs.tsx @@ -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>(() => { + const initial: Record = {}; + 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 ( +
+

Popular Songs

+
+ {songs.map((song, index) => ( +
+ {/* Rank */} +
+ {index + 1} +
+ + {/* Album Art */} +
+ {song.coverArt && api && ( + {song.album} + )} +
+ +
+
+ + {/* Song Info */} +
+
{song.title}
+
{song.album}
+
+ + {/* Play Count */} + {song.playCount && song.playCount > 0 && ( +
+ {song.playCount.toLocaleString()} plays +
+ )} + + {/* Duration */} +
+ {formatDuration(song.duration)} +
+ + {/* Star Button */} + +
+ ))} +
+
+ ); +} diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index c054919..72a5cf9 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -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 (
- {/* top right add the logo located in /icon-192.png here and the word mice */}
Logo mice | navidrome client
- +
-
+
); } return <>{children}; @@ -43,6 +81,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo {children} + diff --git a/app/components/SimilarArtists.tsx b/app/components/SimilarArtists.tsx new file mode 100644 index 0000000..b94cb7d --- /dev/null +++ b/app/components/SimilarArtists.tsx @@ -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([]); + 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 ( +
+

Fans also like

+ +
+ {similarArtists.map((artist) => ( + +
+
+ {artist.name} +
+
+

+ {artist.name} +

+
+
+ + ))} +
+ +
+
+ ); +} diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx new file mode 100644 index 0000000..8ae0aa8 --- /dev/null +++ b/app/components/WhatsNewPopup.tsx @@ -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('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]) => ( +
+ {changelog.title && ( +
+

{changelog.title}

+
+ )} + + {changelog.changes.length > 0 && ( +
+

+ ✨ New Features & Improvements +

+
    + {changelog.changes.map((change, index) => ( +
  • + + {change} +
  • + ))} +
+
+ )} + + {changelog.fixes.length > 0 && ( +
+

+ 🐛 Bug Fixes +

+
    + {changelog.fixes.map((fix, index) => ( +
  • + + {fix} +
  • + ))} +
+
+ )} + + {changelog.breaking.length > 0 && ( +
+

+ ⚠️ Breaking Changes +

+
    + {changelog.breaking.map((breaking, index) => ( +
  • + + {breaking} +
  • + ))} +
+
+ )} +
+ ); + + return ( + + + +
+ + What's New in Mice + + {tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version} + + +
+
+ + {/* Tabs */} + <> +
+ + + {tab === 'archive' && archiveChangelogs.length > 0 && ( + + )} +
+ + {tab === 'latest' + ? renderChangelog(currentVersionChangelog) + : archiveChangelog && renderChangelog(archiveChangelog)} + + +
+ +
+ +
+
+ ); +} + diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 8b3e2d4..3f7611d 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -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 { 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({
-
+ handleClick()}> +
+ {album.coverArt && api ? ( + {album.name} + ) : ( +
+ +
+ )} +
+ handlePlayAlbum(album)}/> +
+
+ +

{album.name}

+

router.push(album.artistId)}>{album.artist}

+

+ {album.songCount} songs • {Math.floor(album.duration / 60)} min +

+
+
+ {/*
{album.name} -
+
*/}
@@ -122,12 +195,6 @@ export function AlbumArtwork({ Share
-
-

{album.name}

-

- {album.artist} -

-
) } \ No newline at end of file diff --git a/app/components/artist-icon.tsx b/app/components/artist-icon.tsx index c9431ef..59230f5 100644 --- a/app/components/artist-icon.tsx +++ b/app/components/artist-icon.tsx @@ -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 { 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 ( +
+ {artist.name} +
+ ); + } + return (
-
handleClick()}> +
+
+ {artist.name} +
+
+ +

{artist.name}

+

+ {artist.albumCount} albums +

+
+ + {/*
-
+
*/}
@@ -117,10 +161,6 @@ export function ArtistIcon({ Share
-
-

{artist.name}

-

{artist.albumCount} albums

-
); } diff --git a/app/components/ihateserverside.tsx b/app/components/ihateserverside.tsx index 4b27d23..073fd66 100644 --- a/app/components/ihateserverside.tsx +++ b/app/components/ihateserverside.tsx @@ -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 = ({ 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 ( +
+ {/* Top Menu */} +
+ setIsSidebarVisible(!isSidebarVisible)} + isSidebarVisible={isSidebarVisible} + toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)} + isStatusBarVisible={isStatusBarVisible} + /> +
+ + {/* Main Content Area */} +
+
+ +
+
+
{children}
+
+
+ + {/* Floating Audio Player */} + + +
+ ); + } return (
{/* Top Menu */} @@ -43,10 +103,12 @@ const Ihateserverside: React.FC = ({ children }) => { {/* Main Content Area */}
{isSidebarVisible && ( -
+
diff --git a/app/components/loading.tsx b/app/components/loading.tsx index 6e12876..b652758 100644 --- a/app/components/loading.tsx +++ b/app/components/loading.tsx @@ -4,14 +4,31 @@ import React from 'react'; const Loading: React.FC = () => { return ( - <> -
-
-
-

Loading...

-
-
- +
+
+ + + + +

Loading...

+
+
); }; diff --git a/app/components/menu.tsx b/app/components/menu.tsx index b219980..83db205 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -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(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 ( <> -
-
- -
-

j

- +
⌘, - window.close()}> + isClient && window.close()}> Quit Music ⌘Q @@ -288,30 +310,21 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
Navidrome URL - {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 Not set; - } catch { - return Invalid config; - } - } - return Not set; - })() - : Not available} + {!isClient ? ( + Loading... + ) : navidromeUrl ? ( + navidromeUrl + ) : ( + Not set + )}
+ + Commit: {process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown'} + Copyright © {new Date().getFullYear()} { 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 ( -
-
+
+ {/* Collapse/Expand Button */} + + +
-

+

Discover

- - - - -
-
-

+

+

Library

- -
+
+
+ + + +
+
); diff --git a/app/components/start-screen.tsx b/app/components/start-screen.tsx index ed71031..f2ffb16 100644 --- a/app/components/start-screen.tsx +++ b/app/components/start-screen.tsx @@ -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({ + Customize Your Experience + {canSkipNavidrome && Step 1 of 1} Configure your preferences to get started @@ -155,6 +243,29 @@ export function LoginForm({
+ {/* Sidebar Settings */} +
+ + +

+ You can always toggle this later using the button in the sidebar +

+
+ {/* Last.fm Scrobbling */}
+ {/* Standalone Last.fm */} +
+ + +

+ {standaloneLastfmEnabled + ? "Direct Last.fm API integration (configure in Settings later)" + : "Use only Navidrome's Last.fm integration"} +

+
+
- + {!hasEnvConfig && ( + + )}
@@ -205,10 +343,17 @@ export function LoginForm({ + Connect to Navidrome + {canSkipNavidrome && {hasEnvConfig ? "Configured via .env" : "Already Connected"}} - 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" + } @@ -269,6 +414,17 @@ export function LoginForm({ )} + + {canSkipNavidrome && ( + + )}
diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx new file mode 100644 index 0000000..ce27707 --- /dev/null +++ b/app/favorites/page.tsx @@ -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([]); + const [favoriteSongs, setFavoriteSongs] = useState([]); + const [favoriteArtists, setFavoriteArtists] = useState([]); + 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 ( +
+
+

Please connect to your Navidrome server to view favorites.

+
+
+ ); + } + + return ( +
+
+
+
+

Favorites

+

Your starred albums, songs, and artists

+
+
+ + + + + + Albums ({favoriteAlbums.length}) + + + + Songs ({favoriteSongs.length}) + + + + Artists ({favoriteArtists.length}) + + + + + {loading ? ( +
+

Loading favorite albums...

+
+ ) : favoriteAlbums.length === 0 ? ( +
+ +

No favorite albums yet

+

Star albums to see them here

+
+ ) : ( +
+ {favoriteAlbums.map((album) => ( + +
+ {album.coverArt && api ? ( + {album.name} + ) : ( +
+ +
+ )} +
+ handlePlayAlbum(album)}/> +
+
+ +

{album.name}

+

{album.artist}

+

+ {album.songCount} songs • {Math.floor(album.duration / 60)} min +

+
+
+ ))} +
+ )} +
+ + + {loading ? ( +
+

Loading favorite songs...

+
+ ) : favoriteSongs.length === 0 ? ( +
+ +

No favorite songs yet

+

Star songs to see them here

+
+ ) : ( +
+ {favoriteSongs.map((song, index) => ( +
+
+ {index + 1} +
+
+ {song.coverArt && api ? ( + {song.album} + ) : ( +
+ +
+ )} +
+
+

{song.title}

+

{song.artist}

+
+
{song.album}
+
+ {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} +
+
+ + +
+
+ ))} +
+ )} +
+ + + {loading ? ( +
+

Loading favorite artists...

+
+ ) : favoriteArtists.length === 0 ? ( +
+ +

No favorite artists yet

+

Star artists to see them here

+
+ ) : ( +
+ {favoriteArtists.map((artist) => ( + + +
+ {artist.name} +
+

{artist.name}

+

+ {artist.albumCount} albums +

+
+
+ ))} +
+ )} +
+
+
+
+ ); +}; + +export default FavoritesPage; diff --git a/app/globals.css b/app/globals.css index 74a7e6a..30d4145 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { diff --git a/app/library/artists/page.tsx b/app/library/artists/page.tsx index 5c05a4b..c06ca8d 100644 --- a/app/library/artists/page.tsx +++ b/app/library/artists/page.tsx @@ -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([]); 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() {
-
+
{filteredArtists.map((artist) => ( - + +
handleViewArtist(artist)}> +
+ {artist.name} +
+
+
+
+ +

{artist.name}

+

+ {artist.albumCount} albums +

+
+
))}
diff --git a/app/library/songs/page.tsx b/app/library/songs/page.tsx index a1d8581..3e76b59 100644 --- a/app/library/songs/page.tsx +++ b/app/library/songs/page.tsx @@ -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); diff --git a/app/page.tsx b/app/page.tsx index adf7c4e..4181622 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,9 +11,11 @@ import { useNavidromeConfig } from './components/NavidromeConfigContext'; type TimeOfDay = 'morning' | 'afternoon' | 'evening'; export default function MusicPage() { - const { albums, isLoading } = useNavidrome(); + const { albums, isLoading, api, isConnected } = useNavidrome(); const [recentAlbums, setRecentAlbums] = useState([]); const [newestAlbums, setNewestAlbums] = useState([]); + const [favoriteAlbums, setFavoriteAlbums] = useState([]); + 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) => ( -
+
)) ) : ( recentAlbums.map((album) => ( @@ -106,6 +126,46 @@ export default function MusicPage() {
+ + {/* Favorite Albums Section */} + {favoriteAlbums.length > 0 && ( + <> +
+

+ Favorite Albums +

+

+ Your starred albums collection. +

+
+ +
+ +
+ {favoritesLoading ? ( + // Loading skeletons + Array.from({ length: 6 }).map((_, i) => ( +
+ )) + ) : ( + favoriteAlbums.map((album) => ( + + )) + )} +
+ + +
+ + )} +

Your Library @@ -121,7 +181,7 @@ export default function MusicPage() { {isLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => ( -

+
)) ) : ( newestAlbums.map((album) => ( diff --git a/app/playlist/[id]/page.tsx b/app/playlist/[id]/page.tsx index b1b957d..749abbd 100644 --- a/app/playlist/[id]/page.tsx +++ b/app/playlist/[id]/page.tsx @@ -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 diff --git a/app/queue/page.tsx b/app/queue/page.tsx index f6c5ef4..1cccfb6 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -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 && (

Now Playing

-
+
{/* Album Art */}
@@ -65,21 +65,13 @@ const QueuePage: React.FC = () => {

{currentTrack.name}

-
- {currentTrack.artist}
-
- - - {currentTrack.album} - -
@@ -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 */} -
- {index + 1} - -
- - {/* Album Art */} -
+ {/* Album Art with Play Indicator */} +
{track.album} { height={48} className="w-full h-full object-cover rounded-md" /> +
+ +
{/* Song Info */} @@ -146,7 +135,6 @@ const QueuePage: React.FC = () => {
- { {track.artist}
-
- - e.stopPropagation()} - > - {track.album} - -
diff --git a/app/search/page.tsx b/app/search/page.tsx index 76f6809..b626008 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -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); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index dde6fe8..6f172bd 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 ( -
+

Settings

Customize your music experience

- - - - - Navidrome Server - - - Configure connection to your Navidrome music server - - + {!hasEnvConfig && ( + + + + + Navidrome Server + + + Configure connection to your Navidrome music server + +
@@ -228,6 +366,35 @@ const SettingsPage = () => {
+ )} + + {hasEnvConfig && ( + + + + + Navidrome Server + + + Using environment variables configuration + + + +
+ +
+

Configured via Environment Variables

+

Server: {process.env.NEXT_PUBLIC_NAVIDROME_URL}

+

Username: {process.env.NEXT_PUBLIC_NAVIDROME_USERNAME}

+
+
+

+ Your Navidrome connection is configured through environment variables. + Contact your administrator to change these settings. +

+
+
+ )} @@ -276,6 +443,167 @@ const SettingsPage = () => { + {/* + + + + Application Settings + + + General application preferences and setup + + + +
+ + +

+ Re-run the initial setup wizard to configure your preferences from scratch +

+
+
+
*/} + + + + + + Sidebar Settings + + + Customize sidebar appearance and behavior + + + +
+ + +
+ +
+

Expanded: Shows full navigation labels

+

Collapsed: Shows only icons with tooltips

+

Note: You can also toggle the sidebar using the collapse button in the sidebar.

+
+
+
+ + + + + + Standalone Last.fm Integration + + + Direct Last.fm scrobbling without Navidrome configuration + + + +
+ + +
+ + {standaloneLastFmEnabled && ( + <> +
+ + setLastFmCredentials(prev => ({ ...prev, apiKey: e.target.value }))} + /> +
+ +
+ + setLastFmCredentials(prev => ({ ...prev, apiSecret: e.target.value }))} + /> +
+ + {lastFmCredentials.sessionKey ? ( +
+ + + Authenticated as {lastFmCredentials.username} + +
+ ) : ( +
+ + Not authenticated +
+ )} + +
+ + +
+ +
+

Setup Instructions:

+
    +
  1. Create a Last.fm API account at last.fm/api
  2. +
  3. Enter your API key and secret above
  4. +
  5. Save credentials and click "Authorize with Last.fm"
  6. +
  7. Complete the authorization process
  8. +
+

Features:

+
    +
  • Direct scrobbling to Last.fm (independent of Navidrome)
  • +
  • "Now Playing" updates
  • +
  • Follows Last.fm scrobbling rules (30s minimum or 50% played)
  • +
+
+ + )} +
+
+ Appearance diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 1647513..9bf4759 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + hideCloseButton?: boolean; + } +>(({ className, children, hideCloseButton = false, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} )) diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..694de0a --- /dev/null +++ b/docker-compose.override.yml.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7bb4765 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/hooks/use-standalone-lastfm.ts b/hooks/use-standalone-lastfm.ts new file mode 100644 index 0000000..23ad065 --- /dev/null +++ b/hooks/use-standalone-lastfm.ts @@ -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({ + 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, 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): Promise => { + const credentials = getCredentials(); + if (!credentials) throw new Error('No Last.fm credentials'); + + const requestParams: Record = { + ...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 = { + 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 + }; +} diff --git a/lib/lastfm-api.ts b/lib/lastfm-api.ts new file mode 100644 index 0000000..36258b8 --- /dev/null +++ b/lib/lastfm-api.ts @@ -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): Promise> { + 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 { + 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 { + 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 { + 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(); diff --git a/lib/navidrome.ts b/lib/navidrome.ts index 069aa12..6f162ab 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -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 { + 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 { + 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 diff --git a/next.config.mjs b/next.config.mjs index 1ca7439..5a4e860 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,6 +7,7 @@ const nextConfig = { hostname: "**", } ], + qualities: [ 45, 75, 85, 90, 100 ] }, async headers() { return [ diff --git a/package-lock.json b/package-lock.json index 372d422..7918176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bba95a3..6f1e29f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "mice-reworked", - "version": "1.0.0", + "version": "2025.07.02", "private": true, "scripts": { "predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local", - "dev": "next dev -p 40625", + "dev": "next dev --turbopack -p 40625", "build": "next build", "start": "next start -p 40625", "lint": "next lint" @@ -30,11 +30,11 @@ "clsx": "^2.1.1", "colorthief": "^2.6.0", "lucide-react": "^0.469.0", - "next": "^15.0.3", + "next": "15.3.4", "posthog-js": "^1.255.0", "posthog-node": "^5.1.1", - "react": "^19", - "react-dom": "^19", + "react": "19.1.0", + "react-dom": "19.1.0", "react-hook-form": "^7.53.2", "react-icons": "^5.3.0", "tailwind-merge": "^2.5.4", @@ -43,14 +43,18 @@ }, "devDependencies": { "@types/node": "^22.10.4", - "@types/react": "^19.0.4", - "@types/react-dom": "^19.0.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "chalk": "^5.3.0", "eslint": "^9.17", - "eslint-config-next": "15.1.4", + "eslint-config-next": "15.3.4", "postcss": "^8", "tailwindcss": "^3.4.15", "typescript": "^5" }, - "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" + "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a", + "overrides": { + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6" + } }