From f721213c4a6a3af0f8c7e16a4d7f5d91f4dd2338 Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 15:19:17 +0000 Subject: [PATCH 01/27] feat: add standalone Last.fm integration and settings management - Implemented standalone Last.fm integration in the settings page. - Added functionality to manage Last.fm credentials, including API key and secret. - Introduced sidebar settings for toggling between expanded and collapsed views. - Enhanced the Navidrome API with new methods for fetching starred items and album songs. - Created a new Favorites page to display starred albums, songs, and artists with play and toggle favorite options. - Added a Badge component for UI consistency across the application. --- .env.local | 2 +- app/components/AudioPlayer.tsx | 48 ++++- app/components/NavidromeContext.tsx | 6 + app/components/ihateserverside.tsx | 18 +- app/components/sidebar.tsx | 165 ++++++++++++--- app/favorites/page.tsx | 317 ++++++++++++++++++++++++++++ app/settings/page.tsx | 268 ++++++++++++++++++++++- components/ui/badge.tsx | 36 ++++ hooks/use-standalone-lastfm.ts | 244 +++++++++++++++++++++ lib/navidrome.ts | 21 ++ 10 files changed, 1078 insertions(+), 47 deletions(-) create mode 100644 app/favorites/page.tsx create mode 100644 components/ui/badge.tsx create mode 100644 hooks/use-standalone-lastfm.ts diff --git a/.env.local b/.env.local index 149077b..0c27cf7 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=e88d8b2 +NEXT_PUBLIC_COMMIT_SHA=591faca diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 46eb797..32cd440 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -8,6 +8,7 @@ import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVol 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(); @@ -24,14 +25,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 = (track: any) => { + navidromeOnTrackStart(track); + standaloneOnTrackStart(track); + }; + + const onTrackPlay = (track: any) => { + navidromeOnTrackPlay(track); + standaloneOnTrackPlay(track); + }; + + const onTrackPause = (currentTime: number) => { + navidromeOnTrackPause(currentTime); + standaloneOnTrackPause(currentTime); + }; + + const onTrackProgress = (track: any, currentTime: number, duration: number) => { + navidromeOnTrackProgress(track, currentTime, duration); + standaloneOnTrackProgress(track, currentTime, duration); + }; + + const onTrackEnd = (track: any, currentTime: number, duration: number) => { + navidromeOnTrackEnd(track, currentTime, duration); + standaloneOnTrackEnd(track, currentTime, duration); + }; const handleOpenQueue = () => { setIsFullScreen(false); diff --git a/app/components/NavidromeContext.tsx b/app/components/NavidromeContext.tsx index a7e6fcd..0acbade 100644 --- a/app/components/NavidromeContext.tsx +++ b/app/components/NavidromeContext.tsx @@ -4,6 +4,9 @@ import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } import { useCallback } from 'react'; interface NavidromeContextType { + // API instance + api: ReturnType; + // 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/ihateserverside.tsx b/app/components/ihateserverside.tsx index 4b27d23..a44733f 100644 --- a/app/components/ihateserverside.tsx +++ b/app/components/ihateserverside.tsx @@ -15,8 +15,22 @@ const Ihateserverside: React.FC = ({ children }) => { const [isSidebarVisible, setIsSidebarVisible] = useState(true); const [isStatusBarVisible, setIsStatusBarVisible] = useState(true); const [isSidebarHidden, setIsSidebarHidden] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('sidebar-collapsed') === 'true'; + } + return false; + }); const { playlists } = useNavidrome(); + 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 @@ -43,10 +57,12 @@ const Ihateserverside: React.FC = ({ children }) => { {/* Main Content Area */}
{isSidebarVisible && ( -
+
diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index f7dc603..f3840b1 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -7,12 +7,15 @@ import { Button } from "../../components/ui/button"; import { ScrollArea } from "../../components/ui/scroll-area"; import Link from "next/link"; import { Playlist } from "@/lib/navidrome"; +import { ChevronLeft, ChevronRight } from "lucide-react"; interface SidebarProps extends React.HTMLAttributes { playlists: Playlist[]; + collapsed?: boolean; + onToggle?: () => void; } -export function Sidebar({ className, playlists }: SidebarProps) { +export function Sidebar({ className, playlists, collapsed = false, onToggle }: SidebarProps) { const isRoot = usePathname() === "/"; const isBrowse = usePathname() === "/browse"; const isSearch = usePathname() === "/search"; @@ -21,18 +24,35 @@ export function Sidebar({ className, playlists }: SidebarProps) { const isQueue = usePathname() === "/queue"; const isRadio = usePathname() === "/radio"; const isHistory = usePathname() === "/history"; - const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists"; + const isSongs = usePathname() === "/library/songs"; + const isPlaylists = usePathname() === "/library/playlists"; + const isFavorites = usePathname() === "/favorites"; + const isNew = usePathname() === "/new"; return ( -
+
+ {/* Collapse/Expand Button */} + +
-

+

Discover

- - - - -
-

+

Library

- -
diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx new file mode 100644 index 0000000..78cea04 --- /dev/null +++ b/app/favorites/page.tsx @@ -0,0 +1,317 @@ +'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 } from "lucide-react"; +import { useAudioPlayer } 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, + }); + }; + + 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, + })); + + playTrack(tracks[0]); + tracks.slice(1).forEach((track: any) => 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} + ) : ( +
+ +
+ )} +
+ + +
+
+ +

{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.albumCount} albums +

+
+ + +
+
+
+ ))} +
+ )} +
+
+
+
+ ); +}; + +export default FavoritesPage; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index dde6fe8..fe1f930 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,42 @@ 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: '' + }); + + // 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,8 +173,99 @@ 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

@@ -276,6 +406,136 @@ const SettingsPage = () => { + + + + + 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/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/navidrome.ts b/lib/navidrome.ts index 069aa12..c9eeac1 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -482,6 +482,27 @@ 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 []; + } + } } // Singleton instance management From f0f3d5adb1e7d816fa4f913c8b3952d16b7859ec Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 15:59:31 +0000 Subject: [PATCH 02/27] feat: update NEXT_PUBLIC_COMMIT_SHA and add WhatsNewPopup component with changelog functionality --- .env.local | 2 +- app/components/RootLayoutClient.tsx | 2 + app/components/WhatsNewPopup.tsx | 139 ++++++++++++++++++++++++++++ app/components/sidebar.tsx | 86 ++++++++++++----- 4 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 app/components/WhatsNewPopup.tsx diff --git a/.env.local b/.env.local index 0c27cf7..2ae4e13 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=591faca +NEXT_PUBLIC_COMMIT_SHA=f721213 diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index c054919..69ae609 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"; @@ -43,6 +44,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo {children} + diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx new file mode 100644 index 0000000..89b47ce --- /dev/null +++ b/app/components/WhatsNewPopup.tsx @@ -0,0 +1,139 @@ +'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'; +import { X } from 'lucide-react'; + +// Current app version from package.json +const APP_VERSION = '1.0.0'; + +// Changelog data - add new versions at the top +const CHANGELOG = [ + { + version: '1.0.0', + date: '2024-01-10', + title: 'Initial Release', + changes: [ + 'Complete redesign with modern UI', + 'Added Favorites functionality for albums, songs, and artists', + 'Integrated standalone Last.fm scrobbling support', + 'Added collapsible sidebar with icon-only mode', + 'Improved search and browsing experience', + 'Added history tracking for played songs', + 'Enhanced audio player with better controls', + 'Added settings page for customization options' + ], + breaking: [], + fixes: [] + } +]; + +export function WhatsNewPopup() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Check if we've shown the popup for this version + const lastShownVersion = localStorage.getItem('whats-new-last-shown'); + + if (lastShownVersion !== APP_VERSION) { + setIsOpen(true); + } + }, []); + + const handleClose = () => { + // Mark this version as shown + localStorage.setItem('whats-new-last-shown', APP_VERSION); + setIsOpen(false); + }; + + const currentVersionChangelog = CHANGELOG.find(entry => entry.version === APP_VERSION); + + if (!currentVersionChangelog) { + return null; + } + + return ( + + + +
+ + What's New in mice + {currentVersionChangelog.version} + +

+ Released on {currentVersionChangelog.date} +

+
+
+ + +
+ {currentVersionChangelog.title && ( +
+

{currentVersionChangelog.title}

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

+ ✨ New Features & Improvements +

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

+ 🐛 Bug Fixes +

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

+ ⚠️ Breaking Changes +

+
    + {currentVersionChangelog.breaking.map((breaking, index) => ( +
  • + + {breaking} +
  • + ))} +
+
+ )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index f3840b1..09ab212 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -16,18 +16,32 @@ interface SidebarProps extends React.HTMLAttributes { } export function Sidebar({ className, playlists, collapsed = false, onToggle }: 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"; - const isFavorites = usePathname() === "/favorites"; - const isNew = usePathname() === "/new"; + 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 (
@@ -49,7 +63,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S
+
+
+ + + +
+
); From bd764fd9e1a7c8855d5fa035b7b4c5d9b1460203 Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 17:04:42 +0000 Subject: [PATCH 03/27] feat: update onboarding logic, enhance Navidrome connection checks, and improve WhatsNewPopup functionality --- .env.local | 2 +- app/components/RootLayoutClient.tsx | 45 ++++++- app/components/WhatsNewPopup.tsx | 14 ++- app/components/start-screen.tsx | 178 ++++++++++++++++++++++++++-- app/settings/page.tsx | 88 ++++++++++++-- components/ui/dialog.tsx | 16 ++- 6 files changed, 307 insertions(+), 36 deletions(-) diff --git a/.env.local b/.env.local index 2ae4e13..fd84c2e 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=f721213 +NEXT_PUBLIC_COMMIT_SHA=f0f3d5a diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index 69ae609..72a5cf9 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -14,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}; diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index 89b47ce..17dc71d 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -14,17 +14,19 @@ const APP_VERSION = '1.0.0'; const CHANGELOG = [ { version: '1.0.0', - date: '2024-01-10', + date: '2025-07-01', title: 'Initial Release', changes: [ - 'Complete redesign with modern UI', 'Added Favorites functionality for albums, songs, and artists', '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' + 'Added settings page for customization options', + 'Introduced Whats New popup for version updates', + 'Improved UI consistency with new Badge component', ], breaking: [], fixes: [] @@ -35,6 +37,10 @@ export function WhatsNewPopup() { const [isOpen, setIsOpen] = useState(false); useEffect(() => { + // Only show for users who have completed onboarding + const hasCompletedOnboarding = localStorage.getItem('onboarding-completed'); + if (!hasCompletedOnboarding) return; + // Check if we've shown the popup for this version const lastShownVersion = localStorage.getItem('whats-new-last-shown'); @@ -61,7 +67,7 @@ export function WhatsNewPopup() {
- What's New in mice + What's New in mice {currentVersionChangelog.version}

diff --git a/app/components/start-screen.tsx b/app/components/start-screen.tsx index ed71031..6350b32 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 } 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 + useEffect(() => { + checkNavidromeConnection(); + }, []); + + const checkNavidromeConnection = 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'); + } + }; + 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/settings/page.tsx b/app/settings/page.tsx index fe1f930..081e0fe 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -50,6 +50,13 @@ const SettingsPage = () => { 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') { @@ -272,16 +279,17 @@ const SettingsPage = () => {

Customize your music experience

- - - - - Navidrome Server - - - Configure connection to your Navidrome music server - - + {!hasEnvConfig && ( + + + + + Navidrome Server + + + Configure connection to your Navidrome music server + +
@@ -358,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. +

+
+
+ )} @@ -406,6 +443,37 @@ const SettingsPage = () => { + + + + + Application Settings + + + General application preferences and setup + + + +
+ + +

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

+
+
+
+ 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 + + )} )) From 026162e2b52b7099b1bb359feee6c8dd726ad21c Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 17:14:05 +0000 Subject: [PATCH 04/27] feat: simplify track display in Album and Queue pages, removing unnecessary elements and improving UI clarity --- app/album/[id]/page.tsx | 10 +--------- app/queue/page.tsx | 32 ++++++-------------------------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index e066a77..239b60e 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -160,23 +160,15 @@ export default function AlbumPage() { {tracklist.map((song, index) => (
handlePlayClick(song)} > {/* Track Number / Play Indicator */}
- {isCurrentlyPlaying(song) ? ( -
-
-
- ) : ( <> {song.track || index + 1} - )}
{/* Song Info */} diff --git a/app/queue/page.tsx b/app/queue/page.tsx index f6c5ef4..683b02e 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -46,7 +46,7 @@ const QueuePage: React.FC = () => { {currentTrack && (

Now Playing

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

{currentTrack.name}

-
@@ -74,12 +73,6 @@ const QueuePage: React.FC = () => { {currentTrack.artist}
-
- - - {currentTrack.album} - -
@@ -122,14 +115,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 */} @@ -155,16 +145,6 @@ const QueuePage: React.FC = () => { {track.artist}
-
- - e.stopPropagation()} - > - {track.album} - -
From d92eb903655fd2f8d5b5e0da0c0d569afaf45214 Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 17:21:42 +0000 Subject: [PATCH 05/27] feat: remove unnecessary User icon from QueuePage for cleaner UI --- app/queue/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 683b02e..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(); @@ -68,7 +68,6 @@ const QueuePage: React.FC = () => {
- {currentTrack.artist} @@ -136,7 +135,6 @@ const QueuePage: React.FC = () => {
- Date: Tue, 1 Jul 2025 17:22:18 +0000 Subject: [PATCH 06/27] feat: enhance album page by managing starred songs and removing unnecessary User icon --- app/album/[id]/page.tsx | 63 +++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 239b60e..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; @@ -182,7 +189,6 @@ export default function AlbumPage() {
- {song.artist}
@@ -194,17 +200,20 @@ export default function AlbumPage() {
{/* Actions */} -
+
From bc159ac20af6df3eb12fbda9617c3caf3c147c8a Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 17:45:39 +0000 Subject: [PATCH 07/27] feat: enhance artist page with popular songs and artist bio sections, update Last.fm API integration --- .env.local | 2 +- app/artist/[artist]/page.tsx | 34 ++++++- app/components/ArtistBio.tsx | 106 ++++++++++++++++++++ app/components/PopularSongs.tsx | 147 ++++++++++++++++++++++++++++ app/components/SimilarArtists.tsx | 93 ++++++++++++++++++ app/components/WhatsNewPopup.tsx | 2 +- app/components/sidebar.tsx | 2 +- app/settings/page.tsx | 31 ------ lib/lastfm-api.ts | 154 ++++++++++++++++++++++++++++++ lib/navidrome.ts | 20 ++++ 10 files changed, 553 insertions(+), 38 deletions(-) create mode 100644 app/components/ArtistBio.tsx create mode 100644 app/components/PopularSongs.tsx create mode 100644 app/components/SimilarArtists.tsx create mode 100644 lib/lastfm-api.ts diff --git a/.env.local b/.env.local index fd84c2e..a7d1334 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=f0f3d5a +NEXT_PUBLIC_COMMIT_SHA=3ca162e 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/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/PopularSongs.tsx b/app/components/PopularSongs.tsx new file mode 100644 index 0000000..4664177 --- /dev/null +++ b/app/components/PopularSongs.tsx @@ -0,0 +1,147 @@ +'use client'; +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 + }; + }; + + 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/SimilarArtists.tsx b/app/components/SimilarArtists.tsx new file mode 100644 index 0000000..8cd8e2a --- /dev/null +++ b/app/components/SimilarArtists.tsx @@ -0,0 +1,93 @@ +'use client'; +import { useState, useEffect } from 'react'; +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 index 17dc71d..11b4c1d 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -23,7 +23,7 @@ const CHANGELOG = [ 'Improved search and browsing experience', 'Added history tracking for played songs', 'New Library Artist Page', - 'Enhanced audio player with better controls', + 'Artist page with top songs and albums', 'Added settings page for customization options', 'Introduced Whats New popup for version updates', 'Improved UI consistency with new Badge component', diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index 09ab212..4eb0d2b 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -55,7 +55,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S {collapsed ? : } -
+

Discover diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 081e0fe..82096ac 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -443,37 +443,6 @@ const SettingsPage = () => { - - - - - Application Settings - - - General application preferences and setup - - - -

- - -

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

-
- - - diff --git a/lib/lastfm-api.ts b/lib/lastfm-api.ts new file mode 100644 index 0000000..790980c --- /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 || 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 || 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 || 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 c9eeac1..6f162ab 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -503,6 +503,26 @@ class NavidromeAPI { 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 From 87a2f06053d87001db644328b6f63f93336fa64d Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 21:52:36 +0000 Subject: [PATCH 08/27] feat: update commit SHA, enhance artist page with new layout and favorite functionality, improve settings page with first-time setup option --- .env.local | 2 +- app/components/WhatsNewPopup.tsx | 2 +- app/components/artist-icon.tsx | 66 +++++++++++++++++++++++++++----- app/components/sidebar.tsx | 2 +- app/library/artists/page.tsx | 53 ++++++++++++++++++++----- app/settings/page.tsx | 31 +++++++++++++++ 6 files changed, 134 insertions(+), 22 deletions(-) diff --git a/.env.local b/.env.local index a7d1334..847efe7 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=3ca162e +NEXT_PUBLIC_COMMIT_SHA=bc159ac diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index 11b4c1d..17dc71d 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -23,7 +23,7 @@ const CHANGELOG = [ 'Improved search and browsing experience', 'Added history tracking for played songs', 'New Library Artist Page', - 'Artist page with top songs and albums', + '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', diff --git a/app/components/artist-icon.tsx b/app/components/artist-icon.tsx index c9431ef..7c766a7 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,59 @@ 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 +167,6 @@ export function ArtistIcon({ Share
-
-

{artist.name}

-

{artist.albumCount} albums

-
); } diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index 4eb0d2b..09ab212 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -55,7 +55,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S {collapsed ? : } -
+

Discover diff --git a/app/library/artists/page.tsx b/app/library/artists/page.tsx index 5c05a4b..299a298 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,32 @@ export default function ArtistPage() {

-
+
{filteredArtists.map((artist) => ( - + +
handleViewArtist(artist)}> +
+ {artist.name} +
+
+ +
+
+ +

{artist.name}

+

+ {artist.albumCount} albums +

+
+
))}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 82096ac..081e0fe 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -443,6 +443,37 @@ const SettingsPage = () => { + + + + + Application Settings + + + General application preferences and setup + + + +
+ + +

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

+
+
+
+ From 0cb4f23f12be5fc6b2487a9f35e0c81a2c7f909d Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 22:48:48 +0000 Subject: [PATCH 09/27] feat: update commit SHA, enhance artist and album components with improved layout and functionality, and update favorites page to display artist cover art --- .env.local | 2 +- app/browse/page.tsx | 2 +- app/components/album-artwork.tsx | 88 ++++++++++++++++++++++++++++---- app/components/artist-icon.tsx | 5 -- app/favorites/page.tsx | 8 ++- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/.env.local b/.env.local index 847efe7..d7ce315 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=bc159ac +NEXT_PUBLIC_COMMIT_SHA=87a2f06 diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 3f26460..f89f3ee 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -120,7 +120,7 @@ export default function BrowsePage() { key={artist.id} artist={artist} className="flex-shrink-0" - size={150} + size={190} /> ))}
diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 8b3e2d4..494f33d 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 { 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,46 @@ 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, + })); + + playTrack(tracks[0]); + tracks.slice(1).forEach((track: any) => 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 +111,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 +194,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 7c766a7..894cf7b 100644 --- a/app/components/artist-icon.tsx +++ b/app/components/artist-icon.tsx @@ -98,11 +98,6 @@ export function ArtistIcon({ className="object-cover w-full h-full" />
-
- -

{artist.name}

diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx index 78cea04..03bef8b 100644 --- a/app/favorites/page.tsx +++ b/app/favorites/page.tsx @@ -282,7 +282,13 @@ const FavoritesPage = () => {
- + {artist.name}

{artist.name}

From 4499bdf147a04aefe960b5b0168d58eac6f135db Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 23:33:40 +0000 Subject: [PATCH 10/27] feat: update commit SHA, enhance UI components with improved layouts and functionality, and refine Favorites page with new features --- .env.local | 2 +- app/browse/page.tsx | 2 +- app/components/WhatsNewPopup.tsx | 10 ++++++-- app/components/artist-icon.tsx | 5 ++-- app/components/sidebar.tsx | 10 ++++---- app/favorites/page.tsx | 42 +++++++------------------------- app/library/artists/page.tsx | 5 +--- 7 files changed, 27 insertions(+), 49 deletions(-) diff --git a/.env.local b/.env.local index d7ce315..0b91c98 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=87a2f06 +NEXT_PUBLIC_COMMIT_SHA=0cb4f23 diff --git a/app/browse/page.tsx b/app/browse/page.tsx index f89f3ee..38d937b 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -119,7 +119,7 @@ export default function BrowsePage() { ))} diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index 17dc71d..60f4593 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -17,7 +17,6 @@ const CHANGELOG = [ date: '2025-07-01', title: 'Initial Release', changes: [ - 'Added Favorites functionality for albums, songs, and artists', 'Integrated standalone Last.fm scrobbling support', 'Added collapsible sidebar with icon-only mode', 'Improved search and browsing experience', @@ -27,9 +26,16 @@ const CHANGELOG = [ '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', ], breaking: [], - fixes: [] + 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' + ] } ]; diff --git a/app/components/artist-icon.tsx b/app/components/artist-icon.tsx index 894cf7b..59230f5 100644 --- a/app/components/artist-icon.tsx +++ b/app/components/artist-icon.tsx @@ -83,11 +83,10 @@ export function ArtistIcon({

- + handleClick()}>
handleClick()} >
+
{/* Collapse/Expand Button */} -
+

Discover @@ -178,7 +178,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S

-
+

Library

@@ -322,8 +322,8 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S
-
-
+
+
- + handlePlayAlbum(album)}/>
@@ -277,16 +267,16 @@ const FavoritesPage = () => {

Star artists to see them here

) : ( -
+
{favoriteArtists.map((artist) => ( - -
+ +
{artist.name}
@@ -294,20 +284,6 @@ const FavoritesPage = () => {

{artist.albumCount} albums

-
- - -
))} diff --git a/app/library/artists/page.tsx b/app/library/artists/page.tsx index 299a298..c06ca8d 100644 --- a/app/library/artists/page.tsx +++ b/app/library/artists/page.tsx @@ -104,7 +104,7 @@ export default function ArtistPage() {
-
+
{filteredArtists.map((artist) => (
handleViewArtist(artist)}> @@ -118,9 +118,6 @@ export default function ArtistPage() { />
-
From d6ac2479cbd61ccd52a60901107aa5e19a81847c Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 1 Jul 2025 23:48:23 +0000 Subject: [PATCH 11/27] feat: enhance audio player and favorites functionality with improved type safety, update image handling in components --- app/components/AudioPlayer.tsx | 24 ++++++++++++------------ app/components/PopularSongs.tsx | 5 ++++- app/components/SimilarArtists.tsx | 5 ++++- app/components/album-artwork.tsx | 4 ++-- app/components/start-screen.tsx | 14 +++++++------- app/favorites/page.tsx | 4 ++-- lib/lastfm-api.ts | 8 ++++---- 7 files changed, 35 insertions(+), 29 deletions(-) diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 32cd440..0498e94 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -1,8 +1,8 @@ '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 { Progress } from '@/components/ui/progress'; @@ -44,30 +44,30 @@ export const AudioPlayer: React.FC = () => { } = useStandaloneLastFm(); // Combined Last.fm handlers - const onTrackStart = (track: any) => { + const onTrackStart = useCallback((track: Track) => { navidromeOnTrackStart(track); standaloneOnTrackStart(track); - }; + }, [navidromeOnTrackStart, standaloneOnTrackStart]); - const onTrackPlay = (track: any) => { + const onTrackPlay = useCallback((track: Track) => { navidromeOnTrackPlay(track); standaloneOnTrackPlay(track); - }; + }, [navidromeOnTrackPlay, standaloneOnTrackPlay]); - const onTrackPause = (currentTime: number) => { + const onTrackPause = useCallback((currentTime: number) => { navidromeOnTrackPause(currentTime); standaloneOnTrackPause(currentTime); - }; + }, [navidromeOnTrackPause, standaloneOnTrackPause]); - const onTrackProgress = (track: any, currentTime: number, duration: number) => { + const onTrackProgress = useCallback((track: Track, currentTime: number, duration: number) => { navidromeOnTrackProgress(track, currentTime, duration); standaloneOnTrackProgress(track, currentTime, duration); - }; + }, [navidromeOnTrackProgress, standaloneOnTrackProgress]); - const onTrackEnd = (track: any, currentTime: number, duration: number) => { + const onTrackEnd = useCallback((track: Track, currentTime: number, duration: number) => { navidromeOnTrackEnd(track, currentTime, duration); standaloneOnTrackEnd(track, currentTime, duration); - }; + }, [navidromeOnTrackEnd, standaloneOnTrackEnd]); const handleOpenQueue = () => { setIsFullScreen(false); diff --git a/app/components/PopularSongs.tsx b/app/components/PopularSongs.tsx index 4664177..60e7ee7 100644 --- a/app/components/PopularSongs.tsx +++ b/app/components/PopularSongs.tsx @@ -1,4 +1,5 @@ 'use client'; +import Image from 'next/image'; import { Song } from '@/lib/navidrome'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { Button } from '@/components/ui/button'; @@ -92,9 +93,11 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) { {/* Album Art */}
{song.coverArt && api && ( - {song.album} )} diff --git a/app/components/SimilarArtists.tsx b/app/components/SimilarArtists.tsx index 8cd8e2a..b94cb7d 100644 --- a/app/components/SimilarArtists.tsx +++ b/app/components/SimilarArtists.tsx @@ -1,5 +1,6 @@ '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'; @@ -71,9 +72,11 @@ export function SimilarArtists({ artistName }: SimilarArtistsProps) { >
- {artist.name}
diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 494f33d..4cf4247 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -18,7 +18,7 @@ import { 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"; @@ -82,7 +82,7 @@ export function AlbumArtwork({ })); playTrack(tracks[0]); - tracks.slice(1).forEach((track: any) => addToQueue(track)); + tracks.slice(1).forEach((track: Track) => addToQueue(track)); } } catch (error) { console.error('Failed to play album:', error); diff --git a/app/components/start-screen.tsx b/app/components/start-screen.tsx index 6350b32..f2ffb16 100644 --- a/app/components/start-screen.tsx +++ b/app/components/start-screen.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -68,11 +68,7 @@ export function LoginForm({ }, []); // Check if Navidrome is already working on component mount - useEffect(() => { - checkNavidromeConnection(); - }, []); - - const checkNavidromeConnection = async () => { + const checkNavidromeConnection = useCallback(async () => { try { // First check if there's a working API instance const { getNavidromeAPI } = await import('@/lib/navidrome'); @@ -122,7 +118,11 @@ export function LoginForm({ } 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 })); diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx index 1d5d7fa..976fa02 100644 --- a/app/favorites/page.tsx +++ b/app/favorites/page.tsx @@ -9,7 +9,7 @@ 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 } from "@/app/components/AudioPlayerContext"; +import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext"; import Image from "next/image"; const FavoritesPage = () => { @@ -82,7 +82,7 @@ const FavoritesPage = () => { })); playTrack(tracks[0]); - tracks.slice(1).forEach((track: any) => addToQueue(track)); + tracks.slice(1).forEach((track: Track) => addToQueue(track)); } } catch (error) { console.error('Failed to play album:', error); diff --git a/lib/lastfm-api.ts b/lib/lastfm-api.ts index 790980c..36258b8 100644 --- a/lib/lastfm-api.ts +++ b/lib/lastfm-api.ts @@ -73,7 +73,7 @@ export class LastFmAPI { } } - private async makeRequest(method: string, params: Record): Promise { + private async makeRequest(method: string, params: Record): Promise> { const credentials = this.getCredentials(); if (!credentials?.apiKey) { throw new Error('No Last.fm API key available'); @@ -108,7 +108,7 @@ export class LastFmAPI { autocorrect: '1' }); - return data.artist || null; + return (data.artist as LastFmArtistInfo) || null; } catch (error) { console.error('Failed to fetch artist info from Last.fm:', error); return null; @@ -123,7 +123,7 @@ export class LastFmAPI { autocorrect: '1' }); - return data.toptracks || null; + return (data.toptracks as LastFmTopTracks) || null; } catch (error) { console.error('Failed to fetch artist top tracks from Last.fm:', error); return null; @@ -138,7 +138,7 @@ export class LastFmAPI { autocorrect: '1' }); - return data.similarartists || null; + return (data.similarartists as LastFmArtistInfo['similar']) || null; } catch (error) { console.error('Failed to fetch similar artists from Last.fm:', error); return null; From 707960b088641a1bdeb1ebfc63547d29b9de71ac Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 00:37:01 +0000 Subject: [PATCH 12/27] feat: update commit SHA, enhance audio player and full screen player with favorite functionality, and update various components to support starred tracks --- .env.local | 2 +- app/components/AudioPlayer.tsx | 29 +++++++++- app/components/AudioPlayerContext.tsx | 80 ++++++++++++++++++++++++++- app/components/FullScreenPlayer.tsx | 36 ++++++++---- app/components/PopularSongs.tsx | 3 +- app/components/album-artwork.tsx | 1 + app/favorites/page.tsx | 2 + app/library/songs/page.tsx | 6 +- app/playlist/[id]/page.tsx | 9 ++- app/search/page.tsx | 6 +- app/settings/page.tsx | 4 +- 11 files changed, 150 insertions(+), 28 deletions(-) diff --git a/.env.local b/.env.local index 0b91c98..b2174de 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=0cb4f23 +NEXT_PUBLIC_COMMIT_SHA=d6ac247 diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 0498e94..2bed2d4 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -5,13 +5,14 @@ import { useRouter } from 'next/navigation'; 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); @@ -377,6 +378,19 @@ export const AudioPlayer: React.FC = () => {

{currentTrack.artist}

+ {/* Heart icon for favoriting */} +
- {/* faviorte icon or smthing here */}
{/* Control buttons */} +
- {lyrics.length > 0 && ( - - )} + + +
@@ -410,6 +411,17 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl )} + {lyrics.length > 0 && ( + + )} {showVolumeSlider && (
{ url: api?.getStreamUrl(song.id) || '', duration: song.duration, coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined, + starred: !!song.starred }); }; @@ -79,6 +80,7 @@ const FavoritesPage = () => { url: api.getStreamUrl(song.id), duration: song.duration, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined, + starred: !!song.starred })); playTrack(tracks[0]); 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/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/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 081e0fe..6f172bd 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -443,7 +443,7 @@ const SettingsPage = () => { - + {/* @@ -472,7 +472,7 @@ const SettingsPage = () => {

- + */} From 8486cd195f8a3a29243cc5690a0f03df08218de0 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 00:55:55 +0000 Subject: [PATCH 13/27] feat: update commit SHA, enhance AudioPlayer and FullScreenPlayer components with improved layout and functionality, and add context menu support --- .env.local | 2 +- app/components/AudioPlayer.tsx | 9 ++++++--- app/components/FullScreenPlayer.tsx | 15 +++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.env.local b/.env.local index b2174de..8fe37f5 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=d6ac247 +NEXT_PUBLIC_COMMIT_SHA=707960b diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 2bed2d4..98d0065 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -428,12 +428,13 @@ export const AudioPlayer: React.FC = () => {

{currentTrack.artist}

- {/* Control buttons */} + + {/* Control buttons */} +
-
+ @@ -455,6 +456,8 @@ export const AudioPlayer: React.FC = () => { className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`} /> + +
From 79f4a66a353abe02eb4cf7ce5d47d72bd0daf450 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 01:08:27 +0000 Subject: [PATCH 14/27] feat: enhance AudioPlayer component with improved layout, control button sizes, and added progress bar functionality --- app/components/AudioPlayer.tsx | 94 ++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 98d0065..cdec276 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -413,39 +413,45 @@ export const AudioPlayer: React.FC = () => { // Compact floating player (default state) return (
-
-
+
+
+ {/* Track info */}
{currentTrack.name}
-

{currentTrack.name}

-

{currentTrack.artist}

+

{currentTrack.name}

+

{currentTrack.artist}

- {/* Control buttons */} -
- - - - + {/* Center section with controls and progress */} +
+ {/* Control buttons */} +
+ + + + +
+ + {/* Progress bar */} +
+ + {formatTime(audioCurrent?.currentTime ?? 0)} + + + + {formatTime(audioCurrent?.duration ?? 0)} + +
+
- -
-
+ {/* Right side buttons */} +
@@ -488,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 From a0aadf9b26f48e39145e4ed6fe93d9a3b906b987 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 01:09:47 +0000 Subject: [PATCH 15/27] feat: refine AudioPlayer layout by adjusting control button spacing and commenting out progress bar section --- app/components/AudioPlayer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index cdec276..d4f60cc 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -433,7 +433,7 @@ export const AudioPlayer: React.FC = () => { {/* Center section with controls and progress */}
{/* Control buttons */} -
+
{/* Progress bar */} -
+ {/*
{formatTime(audioCurrent?.currentTime ?? 0)} @@ -473,7 +473,7 @@ export const AudioPlayer: React.FC = () => { {formatTime(audioCurrent?.duration ?? 0)} -
+
*/}
{/* Right side buttons */} From 6d5e2d493484449d08b1ad01e0bdfa87aec81416 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 01:37:55 +0000 Subject: [PATCH 16/27] feat: update WhatsNewPopup to reflect new app version and changelog details --- app/components/WhatsNewPopup.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index 60f4593..e5d894b 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -8,14 +8,18 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { X } from 'lucide-react'; // Current app version from package.json -const APP_VERSION = '1.0.0'; +const APP_VERSION = '2025.07.01'; // Changelog data - add new versions at the top + +// title can be like this +// "month New Month Update" +// "month Mid-Month Update" +// "month Final Update" const CHANGELOG = [ { - version: '1.0.0', - date: '2025-07-01', - title: 'Initial Release', + version: '2025.07.01', + title: 'July New Month Update', changes: [ 'Integrated standalone Last.fm scrobbling support', 'Added collapsible sidebar with icon-only mode', @@ -76,9 +80,9 @@ export function WhatsNewPopup() { What's New in mice {currentVersionChangelog.version} -

+ {/*

Released on {currentVersionChangelog.date} -

+

*/}
From 680c50c28479549fe63d3f505c40a5669ee0d8c9 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 01:41:39 +0000 Subject: [PATCH 17/27] feat: enhance WhatsNewPopup with archive tab support and improved changelog display --- app/components/WhatsNewPopup.tsx | 191 +++++++++++++++++++------------ 1 file changed, 119 insertions(+), 72 deletions(-) diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index e5d894b..c0a7ab6 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -5,17 +5,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { X } from 'lucide-react'; // Current app version from package.json const APP_VERSION = '2025.07.01'; // Changelog data - add new versions at the top - -// title can be like this -// "month New Month Update" -// "month Mid-Month Update" -// "month Final Update" const CHANGELOG = [ { version: '2025.07.01', @@ -40,111 +34,164 @@ const CHANGELOG = [ '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(() => { - // Only show for users who have completed onboarding const hasCompletedOnboarding = localStorage.getItem('onboarding-completed'); if (!hasCompletedOnboarding) return; - - // Check if we've shown the popup for this version const lastShownVersion = localStorage.getItem('whats-new-last-shown'); - if (lastShownVersion !== APP_VERSION) { setIsOpen(true); } }, []); const handleClose = () => { - // Mark this version as shown 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 - {currentVersionChangelog.version} + What's New in Mice + + {tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version} + - {/*

- Released on {currentVersionChangelog.date} -

*/}
+ {/* Tabs */} +
+ + + {tab === 'archive' && archiveChangelogs.length > 0 && ( + + )} +
+ -
- {currentVersionChangelog.title && ( -
-

{currentVersionChangelog.title}

-
- )} - - {currentVersionChangelog.changes.length > 0 && ( -
-

- ✨ New Features & Improvements -

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

- 🐛 Bug Fixes -

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

- ⚠️ Breaking Changes -

-
    - {currentVersionChangelog.breaking.map((breaking, index) => ( -
  • - - {breaking} -
  • - ))} -
-
- )} -
+ {tab === 'latest' + ? renderChangelog(currentVersionChangelog) + : archiveChangelog && renderChangelog(archiveChangelog)}
-
+
From dd1a5b1115164f5e226ed4789239bae84e25a7d7 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 04:33:08 +0000 Subject: [PATCH 18/27] feat: update commit SHA and remove unused connection status display from Menu component --- .env.local | 2 +- app/components/menu.tsx | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.env.local b/.env.local index 8fe37f5..2923e0d 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=707960b +NEXT_PUBLIC_COMMIT_SHA=680c50c diff --git a/app/components/menu.tsx b/app/components/menu.tsx index b219980..958d37a 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -83,13 +83,6 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu return ( <>
-
- -
-

j

- Date: Wed, 2 Jul 2025 15:56:13 +0000 Subject: [PATCH 19/27] feat: update commit SHA, remove unused dev.nix and vscode settings, and enhance package.json with new dependencies and scripts --- .env.local | 2 +- .idx/dev.nix | 39 ------------------- .vscode/settings.json | 4 -- app/globals.css | 7 ++-- next.config.mjs | 1 + package-lock.json | 90 ++++++++++++++++++++----------------------- package.json | 20 ++++++---- 7 files changed, 60 insertions(+), 103 deletions(-) delete mode 100644 .idx/dev.nix delete mode 100644 .vscode/settings.json diff --git a/.env.local b/.env.local index 2923e0d..079945a 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=680c50c +NEXT_PUBLIC_COMMIT_SHA=dd1a5b1 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/app/globals.css b/app/globals.css index 74a7e6a..867f995 100644 --- a/app/globals.css +++ b/app/globals.css @@ -284,9 +284,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/next.config.mjs b/next.config.mjs index 1ca7439..bdc5dfe 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,6 +7,7 @@ const nextConfig = { hostname: "**", } ], + qualities: [ 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..25ea726 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "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" + } } From 646f722ce168ec98b33d69eee0646dd19dd1695a Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:33:14 +0000 Subject: [PATCH 20/27] feat: enhance Ihateserverside component with client-side hydration and sidebar functionality --- .env.local | 2 +- app/components/ihateserverside.tsx | 60 ++++++++++++++++++++++++---- app/components/menu.tsx | 63 +++++++++++++++++++----------- 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/.env.local b/.env.local index 079945a..093b263 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=dd1a5b1 +NEXT_PUBLIC_COMMIT_SHA=1c60db5 diff --git a/app/components/ihateserverside.tsx b/app/components/ihateserverside.tsx index a44733f..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,14 +15,17 @@ const Ihateserverside: React.FC = ({ children }) => { const [isSidebarVisible, setIsSidebarVisible] = useState(true); const [isStatusBarVisible, setIsStatusBarVisible] = useState(true); const [isSidebarHidden, setIsSidebarHidden] = useState(false); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { - if (typeof window !== 'undefined') { - return localStorage.getItem('sidebar-collapsed') === 'true'; - } - return 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); @@ -36,6 +39,49 @@ const Ihateserverside: React.FC = ({ children }) => { 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 */} diff --git a/app/components/menu.tsx b/app/components/menu.tsx index 958d37a..d747fd1 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,12 +98,16 @@ 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 ( <> @@ -100,7 +129,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu Preferences ⌘, - window.close()}> + isClient && window.close()}> Quit Music ⌘Q @@ -281,25 +310,13 @@ 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 + )}
From 77bd7ff2401e03c2ee12e7054e84070da39654d3 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:38:09 +0000 Subject: [PATCH 21/27] feat: enhance loading component with SVG spinner and update skeleton sizes in MusicPage --- app/components/loading.tsx | 33 +++++++++++++++++++++++++-------- app/page.tsx | 4 ++-- next.config.mjs | 2 +- 3 files changed, 28 insertions(+), 11 deletions(-) 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/page.tsx b/app/page.tsx index adf7c4e..e2515a4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -88,7 +88,7 @@ export default function MusicPage() { {isLoading ? ( // Loading skeletons Array.from({ length: 6 }).map((_, i) => ( -
+
)) ) : ( recentAlbums.map((album) => ( @@ -121,7 +121,7 @@ export default function MusicPage() { {isLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => ( -
+
)) ) : ( newestAlbums.map((album) => ( diff --git a/next.config.mjs b/next.config.mjs index bdc5dfe..5a4e860 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,7 +7,7 @@ const nextConfig = { hostname: "**", } ], - qualities: [ 75, 85, 90, 100 ] + qualities: [ 45, 75, 85, 90, 100 ] }, async headers() { return [ From 387b5af5c0984452e316f0f860482ed4b34ac6de Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:45:44 +0000 Subject: [PATCH 22/27] feat: update AudioPlayer track name animation and add infinite scroll effect in CSS --- app/components/AudioPlayer.tsx | 4 ++-- app/globals.css | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index d4f60cc..be2ab56 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -370,9 +370,9 @@ export const AudioPlayer: React.FC = () => { height={40} className="w-10 h-10 rounded-md flex-shrink-0" /> -
+
-

+

{currentTrack.name}

diff --git a/app/globals.css b/app/globals.css index 867f995..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%; From f9dfae70d40f36448f75ed747e7aed28c3aafa9f Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:49:10 +0000 Subject: [PATCH 23/27] feat: add favorite albums section with loading state in MusicPage --- app/page.tsx | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index e2515a4..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'; @@ -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 From a854604a7bf6d1a51db18ac63ac9ba66893acbd9 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:52:11 +0000 Subject: [PATCH 24/27] feat: add margin to menu container and display commit SHA in footer --- app/components/menu.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/components/menu.tsx b/app/components/menu.tsx index d747fd1..83db205 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -111,7 +111,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu return ( <> -

+
+ + Commit: {process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown'} + Copyright © {new Date().getFullYear()} Date: Wed, 2 Jul 2025 16:52:31 +0000 Subject: [PATCH 25/27] feat: update NEXT_PUBLIC_COMMIT_SHA to reflect latest commit --- .env.local | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.local b/.env.local index 093b263..26ca566 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=1c60db5 +NEXT_PUBLIC_COMMIT_SHA=a854604 From 4fe02675ecff0fd695def02857a4d8f452504c59 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 16:57:24 +0000 Subject: [PATCH 26/27] feat: update app version to 2025.07.02 and add changelog entries for July Mini Update --- app/components/WhatsNewPopup.tsx | 19 +++++++++++++++++-- package.json | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/components/WhatsNewPopup.tsx b/app/components/WhatsNewPopup.tsx index c0a7ab6..8ae0aa8 100644 --- a/app/components/WhatsNewPopup.tsx +++ b/app/components/WhatsNewPopup.tsx @@ -7,10 +7,21 @@ import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; // Current app version from package.json -const APP_VERSION = '2025.07.01'; +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', @@ -25,6 +36,8 @@ const CHANGELOG = [ '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: [ @@ -154,6 +167,7 @@ export function WhatsNewPopup() { {/* Tabs */} + <>
- {tab === 'latest' ? renderChangelog(currentVersionChangelog) @@ -196,7 +209,9 @@ export function WhatsNewPopup() { Got it!
+
); } + diff --git a/package.json b/package.json index 25ea726..6f1e29f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", From d7f4894c7c267a584094fea5f509c86207e360ab Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 2 Jul 2025 19:10:32 +0000 Subject: [PATCH 27/27] feat: add Docker support with environment configuration and health checks --- .dockerignore | 25 ++++ .env.docker | 21 ++++ DOCKER.md | 171 ++++++++++++++++++++++++++++ Dockerfile | 38 +++++++ README.md | 40 ++++++- docker-compose.override.yml.example | 25 ++++ docker-compose.yml | 31 +++++ 7 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.docker create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.override.yml.example create mode 100644 docker-compose.yml 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/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/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