diff --git a/.dockerignore b/.dockerignore index 99d801a..99da753 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,3 +22,18 @@ build .turbo .github 4xnored.png + +# Documentation and non-runtime files +docs/ +CHANGELOG.md +cliff.toml +*.md +!README.md + +# Docker compose files +docker-compose*.yml +Dockerfile + +# Git and backup files +.git* +backup-* diff --git a/.env.docker b/.env.docker index 19b8e69..70308e6 100644 --- a/.env.docker +++ b/.env.docker @@ -11,10 +11,6 @@ PORT=3000 # NAVIDROME_USERNAME=your_username # NAVIDROME_PASSWORD=your_password -# PostHog Analytics (optional) -POSTHOG_KEY= -POSTHOG_HOST= - # Example for external Navidrome server: # NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_USERNAME=your_username diff --git a/.env.example b/.env.example index 2b5462b..5783865 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,9 @@ NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 NEXT_PUBLIC_NAVIDROME_USERNAME=your_username NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password -# PostHog Analytics (optional) -NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com - # For Docker deployment, use these variable names in your .env file: # NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_USERNAME=your_username # NAVIDROME_PASSWORD=your_password -# POSTHOG_KEY=your_posthog_key -# POSTHOG_HOST=https://us.i.posthog.com # HOST_PORT=3000 # PORT=3000 diff --git a/.env.local b/.env.local index b43af3d..ae4cef1 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=0c32c05 +NEXT_PUBLIC_COMMIT_SHA=b5fc053 diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..ca511e6 --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,33 @@ +name: GitHub Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip header + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.changelog.outputs.content }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore index 467434a..6a6d265 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,11 @@ next-env.d.ts # database still-database/ +# Debug related files +scripts/sleep-debug.js +.vscode/launch.json +source-map-support/ + .next/ certificates .vercel diff --git a/.vscode/launch.json b/.vscode/launch.json index 8e746ed..eaf801b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,22 @@ { "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Merow", + "program": "${workspaceFolder}/scripts/sleep-debug.js", + "skipFiles": [ + "/**" + ], + "console": "integratedTerminal", + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "trace": true + }, { "name": "Debug: Next.js Development", "type": "node", @@ -17,7 +33,34 @@ "resolveSourceMapLocations": [ "${workspaceFolder}/**", "!**/node_modules/**" - ] + ], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } + }, + { + "name": "Debug: Development (Verbose)", + "type": "node", + "request": "launch", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "cwd": "${workspaceFolder}", + "env": { + "NODE_ENV": "development", + "DEBUG": "*", + "NEXT_TELEMETRY_DISABLED": "1" + }, + "console": "integratedTerminal", + "skipFiles": ["/**"], + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } }, { "name": "Debug: Next.js Production", @@ -32,7 +75,11 @@ "preLaunchTask": "Build: Production Build Only", "runtimeExecutable": "pnpm", "runtimeArgs": ["run", "start"], - "skipFiles": ["/**"] + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82b06c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to this project will be documented in this file. +## [unreleased] + +### Bug Fixes + +- Use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code +- Docker startup issue, add GitHub release workflow and changelog config + +### Documentation + +- Add CHANGELOG and commit rewriting script + +### Features + +- Implement offline library management with IndexedDB support +- Implement offline library synchronization with IndexedDB +- Add page transition animations and notification settings for audio playback +- Enhance UI with Framer Motion animations for album artwork and artist icons +- Update cover art retrieval to use higher resolution images and enhance download manager with new features +- Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component +- Implement Auto-Tagging Settings and MusicBrainz integration +- Enhance OfflineManagement component with improved card styling and layout +- Refactor service worker registration and enhance offline download manager with client-side checks +- Move service worker registration to a dedicated component for improved client-side handling +- Add ListeningStreakCard component for tracking listening streaks +- Add keyboard shortcuts and queue management features +- Improve SortableQueueItem component with enhanced click handling and styling +- Add pagination to library/songs and remove listening streaks +- Fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists + +### Miscellaneous + +- C +- Merge pull request #39 from sillyangel/dependabot/npm_and_yarn/dev-99ea30e4b7 +- Remove PostHog analytics and update dependencies to latest minor versions +- Update pnpm-lock.yaml to match new overrides configuration +- Update version to 2026.01.24 and add changelog for January 2026 release +- Organize documentation - move markdown files to docs/ folder + +### Refactoring + +- Remove all offline download and caching functionality +- Simplify service worker by removing offline download functionality + +### Styling + +- Update README formatting and improve content clarity + diff --git a/Dockerfile b/Dockerfile index a3d167a..929ce82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,9 @@ COPY README.md /app/ ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD -ENV NEXT_PUBLIC_POSTHOG_KEY=NEXT_PUBLIC_POSTHOG_KEY -ENV NEXT_PUBLIC_POSTHOG_HOST=NEXT_PUBLIC_POSTHOG_HOST +ENV NEXT_PUBLIC_COMMIT_SHA=docker-build 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 diff --git a/README.md b/README.md index 073694c..b34bf99 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,7 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n - **Search** - Find music across your entire library - **Audio Player** with queue management - **Scrobbling** - Track your listening history -- **Playlist Management** - Create and manage playlists -- **Caching** - Cache/Offline save your server + ### Preview ![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true) @@ -57,8 +56,6 @@ Next, open the new `.env` file and update it with your Navidrome server credenti 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 ``` > **Tip:** If you don’t have your own Navidrome server yet, you can use the public demo credentials: diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index bb734cb..bbe5adc 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -124,13 +124,13 @@ export default function AlbumPage() { // Dynamic cover art URLs based on image size const getMobileCoverArtUrl = () => { return album.coverArt && api - ? api.getCoverArtUrl(album.coverArt, 280) + ? api.getCoverArtUrl(album.coverArt, 600) : '/default-user.jpg'; }; const getDesktopCoverArtUrl = () => { return album.coverArt && api - ? api.getCoverArtUrl(album.coverArt, 300) + ? api.getCoverArtUrl(album.coverArt, 600) : '/default-user.jpg'; }; @@ -146,8 +146,8 @@ export default function AlbumPage() { {album.name} @@ -182,8 +182,8 @@ export default function AlbumPage() { {album.name}
@@ -196,9 +196,15 @@ export default function AlbumPage() {

{album.artist}

- + + {/* Controls row */} +
+ +
+ + {/* Album info */}

{album.genre} • {album.year}

{album.songCount} songs, {formatDuration(album.duration)}

diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 940ac72..354dfa5 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -1,90 +1,55 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ 'use client'; -import { useState, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent } from '@/components/ui/tabs'; import { AlbumArtwork } from '@/app/components/album-artwork'; import { ArtistIcon } from '@/app/components/artist-icon'; import { useNavidrome } from '@/app/components/NavidromeContext'; -import { getNavidromeAPI, Album } from '@/lib/navidrome'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; -import { Shuffle } from 'lucide-react'; +import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading'; +import { + Shuffle, + ArrowDown, + RefreshCcw, + Loader2 +} from 'lucide-react'; import Loading from '@/app/components/loading'; +import { useInView } from 'react-intersection-observer'; export default function BrowsePage() { - const { artists, isLoading: contextLoading } = useNavidrome(); + const { artists: allArtists, isLoading: contextLoading } = useNavidrome(); + // Filter to only show album artists (artists with at least one album) + const artists = allArtists.filter(artist => artist.albumCount && artist.albumCount > 0); const { shuffleAllAlbums } = useAudioPlayer(); - const [albums, setAlbums] = useState([]); - const [currentPage, setCurrentPage] = useState(0); - const [isLoadingAlbums, setIsLoadingAlbums] = useState(false); - const [hasMoreAlbums, setHasMoreAlbums] = useState(true); - const albumsPerPage = 84; - - const api = getNavidromeAPI(); - const loadAlbums = async (page: number, append: boolean = false) => { - if (!api) { - console.error('Navidrome API not available'); - return; - } - - try { - setIsLoadingAlbums(true); - const offset = page * albumsPerPage; - - // Use alphabeticalByName to get all albums in alphabetical order - const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset); - - if (append) { - setAlbums(prev => [...prev, ...newAlbums]); - } else { - setAlbums(newAlbums); - } - - // If we got fewer albums than requested, we've reached the end - setHasMoreAlbums(newAlbums.length === albumsPerPage); - } catch (error) { - console.error('Failed to load albums:', error); - } finally { - setIsLoadingAlbums(false); - } - }; - + + // Use our progressive loading hook + const { + albums, + isLoading, + hasMore, + loadMoreAlbums, + refreshAlbums + } = useProgressiveAlbumLoading('alphabeticalByName'); + + // Infinite scroll with intersection observer + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false + }); + + // Load more albums when the load more sentinel comes into view useEffect(() => { - loadAlbums(0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Infinite scroll handler - useEffect(() => { - const handleScroll = (e: Event) => { - const target = e.target as HTMLElement; - if (!target || isLoadingAlbums || !hasMoreAlbums) return; - - const { scrollTop, scrollHeight, clientHeight } = target; - const threshold = 200; // Load more when 200px from bottom - - if (scrollHeight - scrollTop - clientHeight < threshold) { - loadMore(); - } - }; - - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]'); - if (scrollArea) { - scrollArea.addEventListener('scroll', handleScroll); - return () => scrollArea.removeEventListener('scroll', handleScroll); + if (inView && hasMore && !isLoading) { + loadMoreAlbums(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoadingAlbums, hasMoreAlbums, currentPage]); - - const loadMore = () => { - if (isLoadingAlbums || !hasMoreAlbums) return; - const nextPage = currentPage + 1; - setCurrentPage(nextPage); - loadAlbums(nextPage, true); - }; + }, [inView, hasMore, isLoading, loadMoreAlbums]); + + // Pull-to-refresh simulation + const handleRefresh = useCallback(() => { + refreshAlbums(); + }, [refreshAlbums]); if (contextLoading) { return ; @@ -115,12 +80,13 @@ export default function BrowsePage() {
- {artists.map((artist) => ( + {artists.map((artist, index) => ( ))}
@@ -137,13 +103,17 @@ export default function BrowsePage() { Browse the full collection of albums ({albums.length} loaded).

+
- {albums.map((album) => ( + {albums.map((album, index) => ( ))}
- {hasMoreAlbums && ( -
+ {/* Load more sentinel */} + {hasMore && ( +
)} - {!hasMoreAlbums && albums.length > 0 && ( + + {!hasMore && albums.length > 0 && (

All albums loaded ({albums.length} total)

)} + + {albums.length === 0 && !isLoading && ( +
+

No albums found

+

+ Try refreshing or check your connection +

+ +
+ )}
diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 14decf0..75477e1 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -11,9 +11,27 @@ import { useToast } from '@/hooks/use-toast'; import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler'; import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm'; import { useIsMobile } from '@/hooks/use-mobile'; +import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; +import { useGlobalSearch } from './GlobalSearchProvider'; +import { DraggableMiniPlayer } from './DraggableMiniPlayer'; export const AudioPlayer: React.FC = () => { - const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer(); + const { + currentTrack, + playPreviousTrack, + addToQueue, + playNextTrack, + clearQueue, + queue, + toggleShuffle, + shuffle, + toggleCurrentTrackStar, + audioSettings, + updateAudioSettings, + equalizerPreset, + setEqualizerPreset, + audioEffects + } = useAudioPlayer(); const router = useRouter(); const isMobile = useIsMobile(); @@ -31,6 +49,8 @@ export const AudioPlayer: React.FC = () => { const [volume, setVolume] = useState(1); const [isClient, setIsClient] = useState(false); const [isMinimized, setIsMinimized] = useState(false); + // Notifications and title management + const [lastNotifiedTrackId, setLastNotifiedTrackId] = useState(null); const [isFullScreen, setIsFullScreen] = useState(false); const [audioInitialized, setAudioInitialized] = useState(false); const audioCurrent = audioRef.current; @@ -114,16 +134,18 @@ export const AudioPlayer: React.FC = () => { useEffect(() => { setIsClient(true); - // Load saved volume - const savedVolume = localStorage.getItem('navidrome-volume'); - if (savedVolume) { - try { - const volumeValue = parseFloat(savedVolume); - if (volumeValue >= 0 && volumeValue <= 1) { - setVolume(volumeValue); + if (currentTrack) { + // Load saved volume + const savedVolume = localStorage.getItem('navidrome-volume'); + if (savedVolume) { + try { + const volumeValue = parseFloat(savedVolume); + if (volumeValue >= 0 && volumeValue <= 1) { + setVolume(volumeValue); + } + } catch (error) { + console.error('Failed to parse saved volume:', error); } - } catch (error) { - console.error('Failed to parse saved volume:', error); } } @@ -219,17 +241,22 @@ export const AudioPlayer: React.FC = () => { } } keysToRemove.forEach(key => localStorage.removeItem(key)); - }, [isMobile, audioInitialized, volume]); + }, [isMobile, audioInitialized, volume, currentTrack]); // Apply volume to audio element when volume changes useEffect(() => { const audioCurrent = audioRef.current; if (audioCurrent) { - audioCurrent.volume = volume; + // Apply volume through audio effects chain if available + if (audioEffects) { + audioEffects.setVolume(volume); + } else { + audioCurrent.volume = volume; + } } // Save volume to localStorage localStorage.setItem('navidrome-volume', volume.toString()); - }, [volume]); + }, [volume, audioEffects]); // Save position when component unmounts or track changes useEffect(() => { @@ -243,6 +270,7 @@ export const AudioPlayer: React.FC = () => { useEffect(() => { const audioCurrent = audioRef.current; + const preloadAudioCurrent = preloadAudioRef.current; if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) { // Always clear current track time when changing tracks @@ -255,6 +283,20 @@ export const AudioPlayer: React.FC = () => { console.error('❌ Invalid audio URL:', currentTrack.url); return; } + + // If we have audio effects and ReplayGain is enabled, apply it + if (audioEffects && audioSettings.replayGainEnabled && currentTrack.replayGain) { + audioEffects.setReplayGain(currentTrack.replayGain); + } + + // For gapless playback, start preloading the next track + if (audioSettings.gaplessPlayback && queue.length > 0) { + const nextTrack = queue[0]; + if (preloadAudioCurrent && nextTrack) { + preloadAudioCurrent.src = nextTrack.url; + preloadAudioCurrent.load(); + } + } // Debug: Log current audio element state console.log('🔍 Audio element state before loading:', { @@ -368,7 +410,7 @@ export const AudioPlayer: React.FC = () => { setIsPlaying(false); } } - }, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]); + }, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, queue]); useEffect(() => { const audioCurrent = audioRef.current; @@ -397,6 +439,12 @@ export const AudioPlayer: React.FC = () => { // Notify scrobbler about track end onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration); + + // If crossfade is enabled and we have more tracks in queue + if (audioSettings.crossfadeDuration > 0 && queue.length > 0 && audioEffects) { + // Start fading out current track + audioEffects.setCrossfadeTime(audioSettings.crossfadeDuration); + } } playNextTrack(); }; @@ -440,7 +488,50 @@ export const AudioPlayer: React.FC = () => { audioCurrent.removeEventListener('pause', handlePause); } }; - }, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]); + }, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause, audioEffects, audioSettings.crossfadeDuration, queue.length]); + + // Update document title and optionally show a notification when a new song starts + useEffect(() => { + if (!isClient || !currentTrack) { + if (!currentTrack) { + document.title = 'mice'; + } + return; + } + + // Update favicon/title like Spotify + const baseTitle = `${currentTrack.name} • ${currentTrack.artist} – mice`; + document.title = isPlaying ? baseTitle : `(Paused) ${baseTitle}`; + + // Notifications + const notifyEnabled = localStorage.getItem('playback-notifications-enabled') === 'true'; + const canNotify = 'Notification' in window && Notification.permission !== 'denied'; + if (notifyEnabled && canNotify && lastNotifiedTrackId !== currentTrack.id) { + try { + if (Notification.permission === 'default') { + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + new Notification('Now Playing', { + body: `${currentTrack.name} — ${currentTrack.artist}`, + icon: currentTrack.coverArt || '/icon-192.png', + badge: '/icon-192.png', + }); + setLastNotifiedTrackId(currentTrack.id); + } + }); + } else if (Notification.permission === 'granted') { + new Notification('Now Playing', { + body: `${currentTrack.name} — ${currentTrack.artist}`, + icon: currentTrack.coverArt || '/icon-192.png', + badge: '/icon-192.png', + }); + setLastNotifiedTrackId(currentTrack.id); + } + } catch (e) { + console.warn('Notification failed:', e); + } + } + }, [currentTrack, isPlaying, isClient, lastNotifiedTrackId]); // Media Session API integration - Enhanced for mobile useEffect(() => { @@ -710,6 +801,34 @@ export const AudioPlayer: React.FC = () => { } } }; + + // Volume control functions for keyboard shortcuts + const handleVolumeUp = useCallback(() => { + setVolume(prevVolume => Math.min(1, prevVolume + 0.1)); + }, []); + + const handleVolumeDown = useCallback(() => { + setVolume(prevVolume => Math.max(0, prevVolume - 0.1)); + }, []); + + const handleToggleMute = useCallback(() => { + setVolume(prevVolume => prevVolume === 0 ? 1 : 0); + }, []); + + const { openSpotlight } = useGlobalSearch(); + + // Set up keyboard shortcuts + useKeyboardShortcuts({ + onPlayPause: togglePlayPause, + onNextTrack: playNextTrack, + onPreviousTrack: playPreviousTrack, + onVolumeUp: handleVolumeUp, + onVolumeDown: handleVolumeDown, + onToggleMute: handleToggleMute, + onSpotlightSearch: openSpotlight, + disabled: !currentTrack || isFullScreen // Disable if no track or in fullscreen (let FullScreenPlayer handle it) + }); + const handleVolumeChange = (e: React.ChangeEvent) => { const newVolume = parseFloat(e.target.value); setVolume(newVolume); @@ -819,54 +938,7 @@ export const AudioPlayer: React.FC = () => { if (isMinimized) { return ( <> -
-
setIsMinimized(false)} - > -
- {currentTrack.name} -
-
-

- {currentTrack.name} -

-
-

{currentTrack.artist}

-
- {/* Heart icon for favoriting */} - -
- - - -
-
-
-
+ setIsMinimized(false)} /> {/* Single audio element - shared across all UI states */}