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.
This commit is contained in:
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=e88d8b2
|
||||
NEXT_PUBLIC_COMMIT_SHA=591faca
|
||||
|
||||
@@ -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,15 +25,50 @@ 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);
|
||||
router.push('/queue');
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo }
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface NavidromeContextType {
|
||||
// API instance
|
||||
api: ReturnType<typeof getNavidromeAPI>;
|
||||
|
||||
// Data
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
@@ -387,6 +390,9 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
||||
}, [api, refreshData]);
|
||||
|
||||
const value: NavidromeContextType = {
|
||||
// API instance
|
||||
api,
|
||||
|
||||
// Data
|
||||
albums,
|
||||
artists,
|
||||
|
||||
@@ -15,8 +15,22 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
||||
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
|
||||
const [isStatusBarVisible, setIsStatusBarVisible] = useState(true);
|
||||
const [isSidebarHidden, setIsSidebarHidden] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
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<IhateserversideProps> = ({ children }) => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-64 flex-shrink-0 border-r">
|
||||
<div className={`${isSidebarCollapsed ? 'w-16' : 'w-64'} flex-shrink-0 border-r transition-all duration-200`}>
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggle={toggleSidebarCollapse}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
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 (
|
||||
<div className={cn("pb-6", className)}>
|
||||
<div className={cn("pb-6 relative", className)}>
|
||||
{/* Collapse/Expand Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="absolute top-2 right-2 z-10 h-6 w-6 p-0"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||
Discover
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Link href="/">
|
||||
<Button variant={isRoot ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isRoot ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Listen Now" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -41,16 +61,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
Listen Now
|
||||
{!collapsed && "Listen Now"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/browse">
|
||||
<Button variant={isBrowse ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isBrowse ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Browse" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -59,18 +83,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
Browse
|
||||
{!collapsed && "Browse"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/search">
|
||||
<Button variant={isSearch ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isSearch ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Search" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -79,16 +107,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
Search
|
||||
{!collapsed && "Search"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/queue">
|
||||
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isQueue ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Queue" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -97,15 +129,19 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
Queue
|
||||
{!collapsed && "Queue"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/radio">
|
||||
<Button variant={isRadio ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isRadio ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Radio" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -114,7 +150,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||
@@ -122,19 +158,23 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
|
||||
</svg>
|
||||
Radio
|
||||
{!collapsed && "Radio"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||
Library
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Link href="/library/playlists">
|
||||
<Button variant={isPlaylists ? "secondary" : "ghost"} className="w-full justify-start mb-1">
|
||||
<Button
|
||||
variant={isPlaylists ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-1", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Playlists" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -143,7 +183,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M21 15V6" />
|
||||
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
||||
@@ -151,29 +191,59 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
<path d="M16 6H3" />
|
||||
<path d="M12 18H3" />
|
||||
</svg>
|
||||
Playlists
|
||||
{!collapsed && "Playlists"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/songs">
|
||||
<Button variant={isSongs ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Button
|
||||
variant={isSongs ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Songs" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="18" r="4" />
|
||||
<path d="M12 18V2l7 4" />
|
||||
</svg>
|
||||
Songs
|
||||
{!collapsed && "Songs"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/artists">
|
||||
<Button variant={isArtists ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
||||
<Button
|
||||
variant={isArtists ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Artists" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
|
||||
<circle cx="17" cy="7" r="5" />
|
||||
</svg>
|
||||
Artists
|
||||
{!collapsed && "Artists"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/library/albums">
|
||||
<Button variant={isAlbums ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isAlbums ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Albums" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -182,18 +252,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="m16 6 4 14" />
|
||||
<path d="M12 6v14" />
|
||||
<path d="M8 8v12" />
|
||||
<path d="M4 4v16" />
|
||||
</svg>
|
||||
Albums
|
||||
{!collapsed && "Albums"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/history">
|
||||
<Button variant={isHistory ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<Button
|
||||
variant={isHistory ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "History" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -202,12 +276,33 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
|
||||
<path d="M12 8v4l4 2" />
|
||||
</svg>
|
||||
History
|
||||
{!collapsed && "History"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/favorites">
|
||||
<Button
|
||||
variant={isFavorites ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||
title={collapsed ? "Favorites" : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
{!collapsed && "Favorites"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
317
app/favorites/page.tsx
Normal file
317
app/favorites/page.tsx
Normal file
@@ -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<Album[]>([]);
|
||||
const [favoriteSongs, setFavoriteSongs] = useState<Song[]>([]);
|
||||
const [favoriteArtists, setFavoriteArtists] = useState<Artist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFavorites = async () => {
|
||||
if (!api || !isConnected) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [albums, songs, artists] = await Promise.all([
|
||||
api.getAlbums('starred', 100),
|
||||
api.getStarred2(),
|
||||
api.getArtists()
|
||||
]);
|
||||
|
||||
setFavoriteAlbums(albums);
|
||||
|
||||
// Filter starred songs and artists from the starred2 response
|
||||
if (songs.starred2) {
|
||||
setFavoriteSongs(songs.starred2.song || []);
|
||||
setFavoriteArtists((songs.starred2.artist || []).filter((artist: Artist) => artist.starred));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load favorites:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFavorites();
|
||||
}, [api, isConnected]);
|
||||
|
||||
const handlePlaySong = (song: Song) => {
|
||||
playTrack({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">Please connect to your Navidrome server to view favorites.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heart className="w-8 h-8 text-red-500" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
|
||||
<p className="text-muted-foreground">Your starred albums, songs, and artists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="albums" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="albums" className="flex items-center gap-2">
|
||||
<Disc className="w-4 h-4" />
|
||||
Albums ({favoriteAlbums.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="songs" className="flex items-center gap-2">
|
||||
<Music className="w-4 h-4" />
|
||||
Songs ({favoriteSongs.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="artists" className="flex items-center gap-2">
|
||||
<Mic className="w-4 h-4" />
|
||||
Artists ({favoriteArtists.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="albums" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite albums...</p>
|
||||
</div>
|
||||
) : favoriteAlbums.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite albums yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star albums to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favoriteAlbums.map((album) => (
|
||||
<Card key={album.id} className="overflow-hidden">
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt)}
|
||||
alt={album.name}
|
||||
fill
|
||||
className="w-full h-full object-cover rounded"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button size="sm" onClick={() => handlePlayAlbum(album)}>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleFavorite(album.id, 'album', !!album.starred)}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${album.starred ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="songs" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite songs...</p>
|
||||
</div>
|
||||
) : favoriteSongs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite songs yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star songs to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{favoriteSongs.map((song, index) => (
|
||||
<div key={song.id} className="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 group">
|
||||
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="w-12 h-12 relative flex-shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt)}
|
||||
alt={song.album}
|
||||
fill
|
||||
className="rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{song.artist}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{song.album}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="ghost" onClick={() => handlePlaySong(song)}>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleFavorite(song.id, 'song', !!song.starred)}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${song.starred ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="artists" className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Loading favorite artists...</p>
|
||||
</div>
|
||||
) : favoriteArtists.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No favorite artists yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Star artists to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favoriteArtists.map((artist) => (
|
||||
<Card key={artist.id} className="overflow-hidden">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4">
|
||||
<ArtistIcon artist={artist} />
|
||||
</div>
|
||||
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.albumCount} albums
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={`/artist/${encodeURIComponent(artist.name)}`}>
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleFavorite(artist.id, 'artist', !!artist.starred)}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${artist.starred ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesPage;
|
||||
@@ -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 (
|
||||
<div className="container mx-auto p-6 max-w-2xl">
|
||||
<div className="container mx-auto p-6 pb-24 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
|
||||
@@ -276,6 +406,136 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Sidebar Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize sidebar appearance and behavior
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sidebar-mode">Sidebar Mode</Label>
|
||||
<Select
|
||||
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||
onValueChange={(value) => handleSidebarToggle(value === "collapsed")}
|
||||
>
|
||||
<SelectTrigger id="sidebar-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>Expanded:</strong> Shows full navigation labels</p>
|
||||
<p><strong>Collapsed:</strong> Shows only icons with tooltips</p>
|
||||
<p className="mt-3"><strong>Note:</strong> You can also toggle the sidebar using the collapse button in the sidebar.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
Standalone Last.fm Integration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Direct Last.fm scrobbling without Navidrome configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="standalone-lastfm-enabled">Enable Standalone Last.fm</Label>
|
||||
<Select
|
||||
value={standaloneLastFmEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => handleStandaloneLastFmToggle(value === "enabled")}
|
||||
>
|
||||
<SelectTrigger id="standalone-lastfm-enabled">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{standaloneLastFmEnabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastfm-api-key">Last.fm API Key</Label>
|
||||
<Input
|
||||
id="lastfm-api-key"
|
||||
type="text"
|
||||
placeholder="Your Last.fm API key"
|
||||
value={lastFmCredentials.apiKey}
|
||||
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastfm-api-secret">Last.fm API Secret</Label>
|
||||
<Input
|
||||
id="lastfm-api-secret"
|
||||
type="password"
|
||||
placeholder="Your Last.fm API secret"
|
||||
value={lastFmCredentials.apiSecret}
|
||||
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastFmCredentials.sessionKey ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<FaCheck className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">
|
||||
Authenticated as {lastFmCredentials.username}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<FaTimes className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-600">Not authenticated</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleLastFmCredentialsSave} variant="outline">
|
||||
Save Credentials
|
||||
</Button>
|
||||
<Button onClick={handleLastFmAuth} disabled={!lastFmCredentials.apiKey || !lastFmCredentials.apiSecret}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Authorize with Last.fm
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>Setup Instructions:</strong></p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>Create a Last.fm API account at <a href="https://www.last.fm/api" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">last.fm/api</a></li>
|
||||
<li>Enter your API key and secret above</li>
|
||||
<li>Save credentials and click "Authorize with Last.fm"</li>
|
||||
<li>Complete the authorization process</li>
|
||||
</ol>
|
||||
<p className="mt-3"><strong>Features:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Direct scrobbling to Last.fm (independent of Navidrome)</li>
|
||||
<li>"Now Playing" updates</li>
|
||||
<li>Follows Last.fm scrobbling rules (30s minimum or 50% played)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
|
||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
244
hooks/use-standalone-lastfm.ts
Normal file
244
hooks/use-standalone-lastfm.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
interface LastFmCredentials {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
sessionKey?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ScrobbleState {
|
||||
trackId: string | null;
|
||||
hasScrobbled: boolean;
|
||||
hasUpdatedNowPlaying: boolean;
|
||||
playStartTime: number;
|
||||
lastPlayedDuration: number;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
albumName?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function useStandaloneLastFm() {
|
||||
const scrobbleStateRef = useRef<ScrobbleState>({
|
||||
trackId: null,
|
||||
hasScrobbled: false,
|
||||
hasUpdatedNowPlaying: false,
|
||||
playStartTime: 0,
|
||||
lastPlayedDuration: 0,
|
||||
});
|
||||
|
||||
const getCredentials = (): LastFmCredentials | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = localStorage.getItem('lastfm-credentials');
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isEnabled = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const enabled = localStorage.getItem('standalone-lastfm-enabled');
|
||||
const credentials = getCredentials();
|
||||
return enabled === 'true' && credentials?.sessionKey;
|
||||
};
|
||||
|
||||
const generateApiSignature = (params: Record<string, string>, secret: string): string => {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}${params[key]}`)
|
||||
.join('');
|
||||
|
||||
// In a real implementation, you'd use a proper crypto library
|
||||
// For demo purposes, this is a simplified version
|
||||
return btoa(sortedParams + secret).substring(0, 32);
|
||||
};
|
||||
|
||||
const makeLastFmRequest = async (method: string, params: Record<string, string>): Promise<any> => {
|
||||
const credentials = getCredentials();
|
||||
if (!credentials) throw new Error('No Last.fm credentials');
|
||||
|
||||
const requestParams: Record<string, string> = {
|
||||
...params,
|
||||
method,
|
||||
api_key: credentials.apiKey,
|
||||
sk: credentials.sessionKey || '',
|
||||
format: 'json'
|
||||
};
|
||||
|
||||
const signature = generateApiSignature(requestParams, credentials.apiSecret);
|
||||
requestParams.api_sig = signature;
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch('https://ws.audioscrobbler.com/2.0/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const updateNowPlaying = useCallback(async (track: Track) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
try {
|
||||
await makeLastFmRequest('track.updateNowPlaying', {
|
||||
track: track.name,
|
||||
artist: track.artist,
|
||||
album: track.albumName || '',
|
||||
duration: track.duration.toString()
|
||||
});
|
||||
|
||||
scrobbleStateRef.current.hasUpdatedNowPlaying = true;
|
||||
console.log('Updated now playing on Last.fm:', track.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to update now playing on Last.fm:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrobbleTrack = useCallback(async (track: Track, timestamp?: number) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
try {
|
||||
await makeLastFmRequest('track.scrobble', {
|
||||
'track[0]': track.name,
|
||||
'artist[0]': track.artist,
|
||||
'album[0]': track.albumName || '',
|
||||
'timestamp[0]': (timestamp || Math.floor(Date.now() / 1000)).toString()
|
||||
});
|
||||
|
||||
console.log('Scrobbled track to Last.fm:', track.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble track to Last.fm:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const shouldScrobble = (playedDuration: number, totalDuration: number): boolean => {
|
||||
// Last.fm scrobbling rules:
|
||||
// - At least 30 seconds played OR
|
||||
// - At least half the track played (whichever is lower)
|
||||
const minimumTime = Math.min(30, totalDuration / 2);
|
||||
return playedDuration >= minimumTime;
|
||||
};
|
||||
|
||||
const onTrackStart = useCallback(async (track: Track) => {
|
||||
// Reset scrobble state for new track
|
||||
scrobbleStateRef.current = {
|
||||
trackId: track.id,
|
||||
hasScrobbled: false,
|
||||
hasUpdatedNowPlaying: false,
|
||||
playStartTime: Date.now(),
|
||||
lastPlayedDuration: 0,
|
||||
};
|
||||
|
||||
// Update now playing on Last.fm
|
||||
await updateNowPlaying(track);
|
||||
}, [updateNowPlaying]);
|
||||
|
||||
const onTrackPlay = useCallback(async (track: Track) => {
|
||||
scrobbleStateRef.current.playStartTime = Date.now();
|
||||
|
||||
// Update now playing if we haven't already for this track
|
||||
if (!scrobbleStateRef.current.hasUpdatedNowPlaying || scrobbleStateRef.current.trackId !== track.id) {
|
||||
await onTrackStart(track);
|
||||
}
|
||||
}, [onTrackStart]);
|
||||
|
||||
const onTrackPause = useCallback((currentTime: number) => {
|
||||
const now = Date.now();
|
||||
const sessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
scrobbleStateRef.current.lastPlayedDuration += sessionDuration;
|
||||
}, []);
|
||||
|
||||
const onTrackProgress = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||
if (!isEnabled() || scrobbleStateRef.current.hasScrobbled) return;
|
||||
|
||||
// Calculate total played time
|
||||
const now = Date.now();
|
||||
const currentSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + currentSessionDuration;
|
||||
|
||||
// Check if we should scrobble
|
||||
if (shouldScrobble(totalPlayedDuration, duration)) {
|
||||
await scrobbleTrack(track);
|
||||
scrobbleStateRef.current.hasScrobbled = true;
|
||||
}
|
||||
}, [scrobbleTrack]);
|
||||
|
||||
const onTrackEnd = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
// Calculate final played duration
|
||||
const now = Date.now();
|
||||
const finalSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + finalSessionDuration;
|
||||
|
||||
// Scrobble if we haven't already and the track qualifies
|
||||
if (!scrobbleStateRef.current.hasScrobbled && shouldScrobble(totalPlayedDuration, duration)) {
|
||||
await scrobbleTrack(track);
|
||||
}
|
||||
}, [scrobbleTrack]);
|
||||
|
||||
const getAuthUrl = (apiKey: string): string => {
|
||||
return `http://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(window.location.origin + '/settings')}`;
|
||||
};
|
||||
|
||||
const getSessionKey = async (token: string, apiKey: string, apiSecret: string): Promise<{ sessionKey: string; username: string }> => {
|
||||
const params: Record<string, string> = {
|
||||
method: 'auth.getSession',
|
||||
token,
|
||||
api_key: apiKey,
|
||||
format: 'json'
|
||||
};
|
||||
|
||||
const signature = generateApiSignature(params, apiSecret);
|
||||
const url = new URL('https://ws.audioscrobbler.com/2.0/');
|
||||
Object.entries({ ...params, api_sig: signature }).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm auth error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.message || 'Last.fm authentication failed');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionKey: data.session.key,
|
||||
username: data.session.name
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
onTrackStart,
|
||||
onTrackPlay,
|
||||
onTrackPause,
|
||||
onTrackProgress,
|
||||
onTrackEnd,
|
||||
isEnabled,
|
||||
getCredentials,
|
||||
getAuthUrl,
|
||||
getSessionKey
|
||||
};
|
||||
}
|
||||
@@ -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<Song[]> {
|
||||
try {
|
||||
const response = await this.makeRequest('getAlbum', { id: albumId });
|
||||
const albumData = response.album as { song?: Song[] };
|
||||
return albumData?.song || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get album songs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance management
|
||||
|
||||
Reference in New Issue
Block a user