feat: add Last.fm scrobbling hook for tracking and scrobbling music playback

This commit is contained in:
2025-06-22 18:19:17 -05:00
parent 78b17bab54
commit 6fcf58e7ba
6 changed files with 336 additions and 2156 deletions

View File

@@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand } from "react-icons/fa6";
import ColorThief from '@neutrixs/colorthief';
import { Progress } from '@/components/ui/progress';
import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
export const AudioPlayer: React.FC = () => {
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue } = useAudioPlayer();
@@ -24,6 +24,15 @@ export const AudioPlayer: React.FC = () => {
const audioCurrent = audioRef.current;
const { toast } = useToast();
// Last.fm scrobbler integration
const {
onTrackStart,
onTrackPlay,
onTrackPause,
onTrackProgress,
onTrackEnd,
} = useLastFmScrobbler();
const handleOpenQueue = () => {
setIsFullScreen(false);
router.push('/queue');
@@ -85,6 +94,9 @@ export const AudioPlayer: React.FC = () => {
audioCurrent.src = currentTrack.url;
// Notify scrobbler about new track
onTrackStart(currentTrack);
// Check for saved timestamp (only restore if more than 10 seconds in)
const savedTime = localStorage.getItem('navidrome-current-track-time');
if (savedTime) {
@@ -112,6 +124,8 @@ export const AudioPlayer: React.FC = () => {
if (currentTrack.autoPlay) {
audioCurrent.play().then(() => {
setIsPlaying(true);
// Notify scrobbler about play
onTrackPlay(currentTrack);
}).catch((error) => {
console.error('Failed to auto-play:', error);
setIsPlaying(false);
@@ -120,7 +134,7 @@ export const AudioPlayer: React.FC = () => {
setIsPlaying(false);
}
}
}, [currentTrack]);
}, [currentTrack, onTrackStart, onTrackPlay]);
useEffect(() => {
const audioCurrent = audioRef.current;
@@ -136,13 +150,19 @@ export const AudioPlayer: React.FC = () => {
localStorage.setItem('navidrome-current-track-time', currentTime.toString());
lastSavedTime = currentTime;
}
// Update scrobbler with progress
onTrackProgress(currentTrack, currentTime, audioCurrent.duration);
}
};
const handleTrackEnd = () => {
if (currentTrack) {
if (currentTrack && audioCurrent) {
// Clear saved time when track ends
localStorage.removeItem('navidrome-current-track-time');
// Notify scrobbler about track end
onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration);
}
playNextTrack();
};
@@ -157,10 +177,16 @@ export const AudioPlayer: React.FC = () => {
const handlePlay = () => {
setIsPlaying(true);
if (currentTrack) {
onTrackPlay(currentTrack);
}
};
const handlePause = () => {
setIsPlaying(false);
if (audioCurrent && currentTrack) {
onTrackPause(audioCurrent.currentTime);
}
};
if (audioCurrent) {
@@ -180,7 +206,7 @@ export const AudioPlayer: React.FC = () => {
audioCurrent.removeEventListener('pause', handlePause);
}
};
}, [playNextTrack, currentTrack]);
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
// Media Session API integration
useEffect(() => {
@@ -202,17 +228,19 @@ export const AudioPlayer: React.FC = () => {
// Set action handlers
navigator.mediaSession.setActionHandler('play', () => {
const audioCurrent = audioRef.current;
if (audioCurrent) {
if (audioCurrent && currentTrack) {
audioCurrent.play();
setIsPlaying(true);
onTrackPlay(currentTrack);
}
});
navigator.mediaSession.setActionHandler('pause', () => {
const audioCurrent = audioRef.current;
if (audioCurrent) {
if (audioCurrent && currentTrack) {
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
}
});
@@ -240,7 +268,7 @@ export const AudioPlayer: React.FC = () => {
navigator.mediaSession.setActionHandler('seekto', null);
}
};
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack]);
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (audioCurrent && currentTrack) {
@@ -255,13 +283,15 @@ export const AudioPlayer: React.FC = () => {
};
const togglePlayPause = () => {
if (audioCurrent) {
if (audioCurrent && currentTrack) {
if (isPlaying) {
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
} else {
audioCurrent.play().then(() => {
setIsPlaying(true);
onTrackPlay(currentTrack);
}).catch((error) => {
console.error('Failed to play audio:', error);
setIsPlaying(false);

View File

@@ -5,7 +5,7 @@ import { Song, Album, Artist } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from "@/hooks/use-toast";
interface Track {
export interface Track {
id: string;
name: string;
url: string;
@@ -90,6 +90,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [currentTrack]);
const songToTrack = useMemo(() => (song: Song): Track => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
id: song.id,
name: song.title,
@@ -115,10 +118,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
const trackWithAutoPlay = { ...track, autoPlay };
setCurrentTrack(trackWithAutoPlay);
// Scrobble the track
api.scrobble(track.id).catch(error => {
console.error('Failed to scrobble track:', error);
});
// Scrobble the track if API is available
if (api) {
api.scrobble(track.id).catch(error => {
console.error('Failed to scrobble track:', error);
});
}
}, [currentTrack, api]);
const addToQueue = useCallback((track: Track) => {
@@ -175,6 +180,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [playedTracks, currentTrack, playTrack]);
const addAlbumToQueue = useCallback(async (albumId: string) => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const { album, songs } = await api.getAlbum(albumId);
@@ -220,6 +234,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [api, songToTrack, toast, shuffle]);
const addArtistToQueue = useCallback(async (artistId: string) => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const { artist, albums } = await api.getArtist(artistId);
@@ -271,6 +294,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
}, [api, songToTrack, toast, shuffle]);
const playAlbum = useCallback(async (albumId: string) => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const { album, songs } = await api.getAlbum(albumId);
@@ -313,6 +345,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [api, playTrack, songToTrack, toast, shuffle]);
const playAlbumFromTrack = useCallback(async (albumId: string, startingSongId: string) => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const { album, songs } = await api.getAlbum(albumId);
@@ -392,6 +433,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [queue.length]);
const shuffleAllAlbums = useCallback(async () => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const albums = await api.getAlbums('alphabeticalByName', 500, 0);
@@ -427,6 +477,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}, [api, songToTrack, toast]);
const playArtist = useCallback(async (artistId: string) => {
if (!api) {
toast({
variant: "destructive",
title: "Configuration Required",
description: "Please configure Navidrome connection in settings",
});
return;
}
setIsLoading(true);
try {
const { artist, albums } = await api.getArtist(artistId);

View File

@@ -9,7 +9,7 @@ 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 } from 'react-icons/fa';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm } from 'react-icons/fa';
const SettingsPage = () => {
const { theme, setTheme } = useTheme();
@@ -23,6 +23,14 @@ const SettingsPage = () => {
});
const [isTesting, setIsTesting] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Last.fm scrobbling settings
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('lastfm-scrobbling-enabled') === 'true';
}
return true;
});
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -115,6 +123,17 @@ const SettingsPage = () => {
});
};
const handleScrobblingToggle = (enabled: boolean) => {
setScrobblingEnabled(enabled);
localStorage.setItem('lastfm-scrobbling-enabled', enabled.toString());
toast({
title: enabled ? "Scrobbling Enabled" : "Scrobbling Disabled",
description: enabled
? "Tracks will now be scrobbled to Last.fm via Navidrome"
: "Last.fm scrobbling has been disabled",
});
};
return (
<div className="container mx-auto p-6 max-w-2xl">
<div className="space-y-6">
@@ -210,6 +229,53 @@ const SettingsPage = () => {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
Last.fm Integration
</CardTitle>
<CardDescription>
Configure Last.fm scrobbling through your Navidrome server
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="scrobbling-enabled">Enable Scrobbling</Label>
<Select
value={scrobblingEnabled ? "enabled" : "disabled"}
onValueChange={(value) => handleScrobblingToggle(value === "enabled")}
>
<SelectTrigger id="scrobbling-enabled">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Tracks are scrobbled to Last.fm through your Navidrome server</li>
<li>Configure Last.fm credentials in your Navidrome admin panel</li>
<li>Scrobbling occurs when you listen to at least 30 seconds or half the track</li>
<li>&quot;Now Playing&quot; updates are sent when tracks start</li>
</ul>
<p className="mt-3"><strong>Note:</strong> Last.fm credentials must be configured in Navidrome, not here.</p>
</div>
{!isConnected && (
<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">Connect to Navidrome first to enable scrobbling</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>