From 4cc59b4c1f4156e63a281527e56abaf6c81d28f1 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 9 Jul 2025 21:39:16 +0000 Subject: [PATCH] feat: Enhance sidebar functionality and favorite albums feature - Updated GitHub workflows to include additional metadata in labels for Docker images. - Modified Dockerfile to copy README.md into the app directory for documentation purposes. - Added favorite albums functionality in the album page, allowing users to mark albums as favorites. - Improved AudioPlayer component to save playback position more frequently. - Refactored sidebar component to include a favorites section and improved navigation. - Introduced useFavoriteAlbums hook to manage favorite albums state and local storage. - Updated settings page to allow users to toggle sidebar visibility. --- .github/workflows/nightly.yml | 5 +- .github/workflows/release.yml | 5 +- Dockerfile | 3 + app/album/[id]/page.tsx | 12 +- app/components/AudioPlayer.tsx | 12 +- app/components/ihateserverside.tsx | 71 +++-- app/components/sidebar.tsx | 497 +++++++++++++++++------------ app/settings/page.tsx | 44 ++- hooks/use-favorite-albums.ts | 79 +++++ 9 files changed, 488 insertions(+), 240 deletions(-) create mode 100644 hooks/use-favorite-albums.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1e1f84b..2f1bee8 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -109,7 +109,10 @@ jobs: context: . push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: | + ${{ steps.meta.outputs.labels }} + org.opencontainers.image.description=$(cat README.md | head -20 | tr '\n' ' ') + org.opencontainers.image.documentation=https://github.com/sillyangel/stillnavidrome/blob/main/README.md platforms: | linux/amd64 linux/arm64/v8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a94a9b..0463bc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,10 @@ jobs: context: . push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: | + ${{ steps.meta.outputs.labels }} + org.opencontainers.image.description=$(cat README.md | head -20 | tr '\n' ' ') + org.opencontainers.image.documentation=https://github.com/sillyangel/stillnavidrome/blob/main/README.md platforms: | linux/amd64 linux/arm64/v8 diff --git a/Dockerfile b/Dockerfile index f82ba18..33f0e65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ RUN pnpm install # Copy source code COPY . . +# Copy README.md to the app directory for documentation +COPY README.md /app/ + # Set environment variable placeholders during build # These will be replaced at runtime with actual values ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 220fadf..c79ecb6 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -12,6 +12,8 @@ import Loading from "@/app/components/loading"; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { getNavidromeAPI } from '@/lib/navidrome'; +import { useFavoriteAlbums } from '@/hooks/use-favorite-albums'; +import { Star } from 'lucide-react'; export default function AlbumPage() { const { id } = useParams(); @@ -22,6 +24,7 @@ export default function AlbumPage() { const [starredSongs, setStarredSongs] = useState>(new Set()); const { getAlbum, starItem, unstarItem } = useNavidrome(); const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer(); + const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums(); const api = getNavidromeAPI(); useEffect(() => { @@ -137,9 +140,16 @@ export default function AlbumPage() {

{album.name}

- +

{album.artist}

diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index f2cb5b2..31e28fe 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -116,7 +116,7 @@ export const AudioPlayer: React.FC = () => { useEffect(() => { const audioCurrent = audioRef.current; return () => { - if (audioCurrent && currentTrack && audioCurrent.currentTime > 10) { + if (audioCurrent && currentTrack && audioCurrent.currentTime > 5) { localStorage.setItem('navidrome-current-track-time', audioCurrent.currentTime.toString()); } }; @@ -134,12 +134,12 @@ export const AudioPlayer: React.FC = () => { // Notify scrobbler about new track onTrackStart(currentTrack); - // Check for saved timestamp (only restore if more than 10 seconds in) + // Check for saved timestamp (only restore if more than 5 seconds in) const savedTime = localStorage.getItem('navidrome-current-track-time'); if (savedTime) { const time = parseFloat(savedTime); - // Only restore if we were at least 10 seconds in and not near the end - if (time > 10 && time < (currentTrack.duration - 30)) { + // Only restore if we were at least 5 seconds in and not near the end + if (time > 5 && time < (currentTrack.duration - 15)) { const restorePosition = () => { if (audioCurrent.readyState >= 2) { // HAVE_CURRENT_DATA audioCurrent.currentTime = time; @@ -181,9 +181,9 @@ export const AudioPlayer: React.FC = () => { if (audioCurrent && currentTrack) { setProgress((audioCurrent.currentTime / audioCurrent.duration) * 100); - // Save current time every 30 seconds, but only if we've moved forward significantly + // Save current time every 10 seconds, but only if we've moved forward significantly const currentTime = audioCurrent.currentTime; - if (Math.abs(currentTime - lastSavedTime) >= 30 && currentTime > 10) { + if (Math.abs(currentTime - lastSavedTime) >= 10 && currentTime > 5) { localStorage.setItem('navidrome-current-track-time', currentTime.toString()); lastSavedTime = currentTime; } diff --git a/app/components/ihateserverside.tsx b/app/components/ihateserverside.tsx index 6ced263..95e2e8d 100644 --- a/app/components/ihateserverside.tsx +++ b/app/components/ihateserverside.tsx @@ -5,7 +5,8 @@ import { Menu } from "@/app/components/menu"; import { Sidebar } from "@/app/components/sidebar"; import { useNavidrome } from "@/app/components/NavidromeContext"; import { AudioPlayer } from "./AudioPlayer"; -import { Toaster } from "@/components/ui/toaster" +import { Toaster } from "@/components/ui/toaster"; +import { useFavoriteAlbums } from "@/hooks/use-favorite-albums"; interface IhateserversideProps { children: React.ReactNode; @@ -18,12 +19,15 @@ const Ihateserverside: React.FC = ({ children }) => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isClient, setIsClient] = useState(false); const { playlists } = useNavidrome(); + const { favoriteAlbums, removeFavoriteAlbum } = useFavoriteAlbums(); // Handle client-side hydration useEffect(() => { setIsClient(true); const savedCollapsed = localStorage.getItem('sidebar-collapsed') === 'true'; + const savedVisible = localStorage.getItem('sidebar-visible') !== 'false'; // Default to true setIsSidebarCollapsed(savedCollapsed); + setIsSidebarVisible(savedVisible); }, []); const toggleSidebarCollapse = () => { @@ -34,6 +38,14 @@ const Ihateserverside: React.FC = ({ children }) => { } }; + const toggleSidebarVisibility = () => { + const newVisible = !isSidebarVisible; + setIsSidebarVisible(newVisible); + if (typeof window !== 'undefined') { + localStorage.setItem('sidebar-visible', newVisible.toString()); + } + }; + const handleTransitionEnd = () => { if (!isSidebarVisible) { setIsSidebarHidden(true); // This will fully hide the sidebar after transition @@ -53,7 +65,7 @@ const Ihateserverside: React.FC = ({ children }) => { }} > setIsSidebarVisible(!isSidebarVisible)} + toggleSidebar={toggleSidebarVisibility} isSidebarVisible={isSidebarVisible} toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)} isStatusBarVisible={isStatusBarVisible} @@ -62,15 +74,19 @@ const Ihateserverside: React.FC = ({ children }) => { {/* Main Content Area */}
-
- -
+ {isSidebarVisible && ( +
+ +
+ )}
{children}
@@ -93,7 +109,7 @@ const Ihateserverside: React.FC = ({ children }) => { }} > setIsSidebarVisible(!isSidebarVisible)} + toggleSidebar={toggleSidebarVisibility} isSidebarVisible={isSidebarVisible} toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)} isStatusBarVisible={isStatusBarVisible} @@ -101,22 +117,25 @@ const Ihateserverside: React.FC = ({ children }) => {
{/* Main Content Area */} -
- {isSidebarVisible && ( -
- +
+ {isSidebarVisible && ( +
+ +
+ )} +
+
{children}
- )} -
-
{children}
-
{/* Floating Audio Player */} {isStatusBarVisible && ( diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index 7f6fa5e..06c4830 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -6,17 +6,33 @@ import { usePathname } from 'next/navigation'; import { Button } from "../../components/ui/button"; import { ScrollArea } from "../../components/ui/scroll-area"; import Link from "next/link"; +import Image from "next/image"; import { Playlist } from "@/lib/navidrome"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useNavidrome } from "./NavidromeContext"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; interface SidebarProps extends React.HTMLAttributes { playlists: Playlist[]; collapsed?: boolean; onToggle?: () => void; + visible?: boolean; + favoriteAlbums?: Array<{id: string, name: string, artist: string, coverArt?: string}>; + onRemoveFavoriteAlbum?: (albumId: string) => void; } -export function Sidebar({ className, playlists, collapsed = false, onToggle }: SidebarProps) { +export function Sidebar({ className, playlists, collapsed = false, onToggle, visible = true, favoriteAlbums = [], onRemoveFavoriteAlbum }: SidebarProps) { const pathname = usePathname(); + const { api } = useNavidrome(); + + if (!visible) { + return null; + } // Define all routes and their active states const routes = { @@ -44,74 +60,20 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S const isAnySidebarRouteActive = Object.values(routes).some(Boolean); return ( -
- {/* Collapse/Expand Button */} - +

- Discover + Navigation

- - - - - - + {/* Search */} + + {/* Home */} + + + + + {/* Queue */} + + {/* Radio */} + + + {/* Artists */} + + + + + {/* Albums */} + + + + + {/* Playlists */} + + + + + {/* Favorites */} + +
-
-
-

- Library -

-
- +
+
+

+ Library +

+
+ {/* Browse */} + - - + + + {/* Songs */} + - - + + + {/* History */} + - - - - - - - - - - -
-
-
-
-
- - + + {/* Favorite Albums Section */} + {favoriteAlbums.length > 0 && ( + <> +
+ {favoriteAlbums.slice(0, 5).map((album) => ( + + + + + + + + { + e.preventDefault(); + e.stopPropagation(); + onRemoveFavoriteAlbum?.(album.id); + }} + className="text-destructive focus:text-destructive" + > + Remove from favorites + + + + ))} + + )} + + {/* Playlists Section */} + {playlists.length > 0 && ( + <> +
+ {playlists.slice(0, 5).map((playlist) => { + const playlistImageUrl = playlist.coverArt && api + ? api.getCoverArtUrl(playlist.coverArt, 32) + : '/play.png'; // fallback to a music icon + + return ( + + + + ); + })} + + )}
+
+
+
+ + + +
+
); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ff6f644..6ff00e8 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -52,6 +52,7 @@ const SettingsPage = () => { // Sidebar settings const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarVisible, setSidebarVisible] = useState(true); // Initialize client-side state after hydration useEffect(() => { @@ -80,6 +81,13 @@ const SettingsPage = () => { setSidebarCollapsed(savedSidebarCollapsed === 'true'); } + const savedSidebarVisible = localStorage.getItem('sidebar-visible'); + if (savedSidebarVisible !== null) { + setSidebarVisible(savedSidebarVisible === 'true'); + } else { + setSidebarVisible(true); // Default to visible + } + // Load Last.fm credentials const storedCredentials = localStorage.getItem('lastfm-credentials'); if (storedCredentials) { @@ -232,6 +240,24 @@ const SettingsPage = () => { } }; + const handleSidebarVisibilityToggle = (visible: boolean) => { + setSidebarVisible(visible); + if (isClient) { + localStorage.setItem('sidebar-visible', visible.toString()); + } + toast({ + title: visible ? "Sidebar Shown" : "Sidebar Hidden", + description: visible + ? "Sidebar is now visible" + : "Sidebar is now hidden", + }); + + // Trigger a custom event to notify the sidebar component + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('sidebar-visibility-toggle', { detail: { visible } })); + } + }; + const handleLastFmAuth = () => { if (!lastFmCredentials.apiKey) { toast({ @@ -527,25 +553,25 @@ const SettingsPage = () => {
- +
-

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.

+

Visible: Sidebar is always shown with icon navigation

+

Hidden: Sidebar is completely hidden for maximum space

+

Note: The sidebar now shows only icons with tooltips on hover for a cleaner interface.

diff --git a/hooks/use-favorite-albums.ts b/hooks/use-favorite-albums.ts new file mode 100644 index 0000000..4f3a520 --- /dev/null +++ b/hooks/use-favorite-albums.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useNavidrome } from '@/app/components/NavidromeContext'; + +export interface FavoriteAlbum { + id: string; + name: string; + artist: string; + coverArt?: string; +} + +export function useFavoriteAlbums() { + const [favoriteAlbums, setFavoriteAlbums] = useState([]); + const { api } = useNavidrome(); + + // Load favorite albums from localStorage + useEffect(() => { + const saved = localStorage.getItem('favorite-albums'); + if (saved) { + try { + setFavoriteAlbums(JSON.parse(saved)); + } catch (error) { + console.error('Failed to parse favorite albums:', error); + } + } + }, []); + + // Save to localStorage when favorites change + useEffect(() => { + localStorage.setItem('favorite-albums', JSON.stringify(favoriteAlbums)); + }, [favoriteAlbums]); + + const addFavoriteAlbum = (album: FavoriteAlbum) => { + setFavoriteAlbums(prev => { + const exists = prev.some(fav => fav.id === album.id); + if (exists) return prev; + return [...prev, album].slice(0, 10); // Keep only 10 favorites + }); + }; + + const removeFavoriteAlbum = (albumId: string) => { + setFavoriteAlbums(prev => prev.filter(fav => fav.id !== albumId)); + }; + + const isFavoriteAlbum = (albumId: string) => { + return favoriteAlbums.some(fav => fav.id === albumId); + }; + + const toggleFavoriteAlbum = async (albumId: string) => { + if (!api) return; + + try { + if (isFavoriteAlbum(albumId)) { + removeFavoriteAlbum(albumId); + } else { + // Fetch album details to add to favorites + const { album } = await api.getAlbum(albumId); + const favoriteAlbum: FavoriteAlbum = { + id: album.id, + name: album.name, + artist: album.artist, + coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined + }; + addFavoriteAlbum(favoriteAlbum); + } + } catch (error) { + console.error('Failed to toggle favorite album:', error); + } + }; + + return { + favoriteAlbums, + addFavoriteAlbum, + removeFavoriteAlbum, + isFavoriteAlbum, + toggleFavoriteAlbum + }; +}