feat: add Last.fm scrobbling hook for tracking and scrobbling music playback
This commit is contained in:
@@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
||||||
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand } from "react-icons/fa6";
|
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 { Progress } from '@/components/ui/progress';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||||
|
|
||||||
export const AudioPlayer: React.FC = () => {
|
export const AudioPlayer: React.FC = () => {
|
||||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue } = useAudioPlayer();
|
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue } = useAudioPlayer();
|
||||||
@@ -24,6 +24,15 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Last.fm scrobbler integration
|
||||||
|
const {
|
||||||
|
onTrackStart,
|
||||||
|
onTrackPlay,
|
||||||
|
onTrackPause,
|
||||||
|
onTrackProgress,
|
||||||
|
onTrackEnd,
|
||||||
|
} = useLastFmScrobbler();
|
||||||
|
|
||||||
const handleOpenQueue = () => {
|
const handleOpenQueue = () => {
|
||||||
setIsFullScreen(false);
|
setIsFullScreen(false);
|
||||||
router.push('/queue');
|
router.push('/queue');
|
||||||
@@ -85,6 +94,9 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
|
|
||||||
audioCurrent.src = currentTrack.url;
|
audioCurrent.src = currentTrack.url;
|
||||||
|
|
||||||
|
// 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 10 seconds in)
|
||||||
const savedTime = localStorage.getItem('navidrome-current-track-time');
|
const savedTime = localStorage.getItem('navidrome-current-track-time');
|
||||||
if (savedTime) {
|
if (savedTime) {
|
||||||
@@ -112,6 +124,8 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
if (currentTrack.autoPlay) {
|
if (currentTrack.autoPlay) {
|
||||||
audioCurrent.play().then(() => {
|
audioCurrent.play().then(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
// Notify scrobbler about play
|
||||||
|
onTrackPlay(currentTrack);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to auto-play:', error);
|
console.error('Failed to auto-play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -120,7 +134,7 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentTrack]);
|
}, [currentTrack, onTrackStart, onTrackPlay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
@@ -136,13 +150,19 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
localStorage.setItem('navidrome-current-track-time', currentTime.toString());
|
localStorage.setItem('navidrome-current-track-time', currentTime.toString());
|
||||||
lastSavedTime = currentTime;
|
lastSavedTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update scrobbler with progress
|
||||||
|
onTrackProgress(currentTrack, currentTime, audioCurrent.duration);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTrackEnd = () => {
|
const handleTrackEnd = () => {
|
||||||
if (currentTrack) {
|
if (currentTrack && audioCurrent) {
|
||||||
// Clear saved time when track ends
|
// Clear saved time when track ends
|
||||||
localStorage.removeItem('navidrome-current-track-time');
|
localStorage.removeItem('navidrome-current-track-time');
|
||||||
|
|
||||||
|
// Notify scrobbler about track end
|
||||||
|
onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration);
|
||||||
}
|
}
|
||||||
playNextTrack();
|
playNextTrack();
|
||||||
};
|
};
|
||||||
@@ -157,10 +177,16 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
if (currentTrack) {
|
||||||
|
onTrackPlay(currentTrack);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePause = () => {
|
const handlePause = () => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
if (audioCurrent && currentTrack) {
|
||||||
|
onTrackPause(audioCurrent.currentTime);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (audioCurrent) {
|
if (audioCurrent) {
|
||||||
@@ -180,7 +206,7 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
audioCurrent.removeEventListener('pause', handlePause);
|
audioCurrent.removeEventListener('pause', handlePause);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [playNextTrack, currentTrack]);
|
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
|
||||||
|
|
||||||
// Media Session API integration
|
// Media Session API integration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,17 +228,19 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
// Set action handlers
|
// Set action handlers
|
||||||
navigator.mediaSession.setActionHandler('play', () => {
|
navigator.mediaSession.setActionHandler('play', () => {
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
if (audioCurrent) {
|
if (audioCurrent && currentTrack) {
|
||||||
audioCurrent.play();
|
audioCurrent.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
onTrackPlay(currentTrack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => {
|
navigator.mediaSession.setActionHandler('pause', () => {
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
if (audioCurrent) {
|
if (audioCurrent && currentTrack) {
|
||||||
audioCurrent.pause();
|
audioCurrent.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
onTrackPause(audioCurrent.currentTime);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,7 +268,7 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
navigator.mediaSession.setActionHandler('seekto', null);
|
navigator.mediaSession.setActionHandler('seekto', null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack]);
|
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
|
||||||
|
|
||||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
if (audioCurrent && currentTrack) {
|
if (audioCurrent && currentTrack) {
|
||||||
@@ -255,13 +283,15 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
const togglePlayPause = () => {
|
||||||
if (audioCurrent) {
|
if (audioCurrent && currentTrack) {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioCurrent.pause();
|
audioCurrent.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
onTrackPause(audioCurrent.currentTime);
|
||||||
} else {
|
} else {
|
||||||
audioCurrent.play().then(() => {
|
audioCurrent.play().then(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
onTrackPlay(currentTrack);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to play audio:', error);
|
console.error('Failed to play audio:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Song, Album, Artist } from '@/lib/navidrome';
|
|||||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -90,6 +90,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [currentTrack]);
|
}, [currentTrack]);
|
||||||
|
|
||||||
const songToTrack = useMemo(() => (song: Song): Track => {
|
const songToTrack = useMemo(() => (song: Song): Track => {
|
||||||
|
if (!api) {
|
||||||
|
throw new Error('Navidrome API not configured');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: song.id,
|
id: song.id,
|
||||||
name: song.title,
|
name: song.title,
|
||||||
@@ -115,10 +118,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
const trackWithAutoPlay = { ...track, autoPlay };
|
const trackWithAutoPlay = { ...track, autoPlay };
|
||||||
setCurrentTrack(trackWithAutoPlay);
|
setCurrentTrack(trackWithAutoPlay);
|
||||||
|
|
||||||
// Scrobble the track
|
// Scrobble the track if API is available
|
||||||
api.scrobble(track.id).catch(error => {
|
if (api) {
|
||||||
console.error('Failed to scrobble track:', error);
|
api.scrobble(track.id).catch(error => {
|
||||||
});
|
console.error('Failed to scrobble track:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [currentTrack, api]);
|
}, [currentTrack, api]);
|
||||||
|
|
||||||
const addToQueue = useCallback((track: Track) => {
|
const addToQueue = useCallback((track: Track) => {
|
||||||
@@ -175,6 +180,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [playedTracks, currentTrack, playTrack]);
|
}, [playedTracks, currentTrack, playTrack]);
|
||||||
|
|
||||||
const addAlbumToQueue = useCallback(async (albumId: string) => {
|
const addAlbumToQueue = useCallback(async (albumId: string) => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { album, songs } = await api.getAlbum(albumId);
|
const { album, songs } = await api.getAlbum(albumId);
|
||||||
@@ -220,6 +234,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [api, songToTrack, toast, shuffle]);
|
}, [api, songToTrack, toast, shuffle]);
|
||||||
|
|
||||||
const addArtistToQueue = useCallback(async (artistId: string) => {
|
const addArtistToQueue = useCallback(async (artistId: string) => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { artist, albums } = await api.getArtist(artistId);
|
const { artist, albums } = await api.getArtist(artistId);
|
||||||
@@ -271,6 +294,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}
|
}
|
||||||
}, [api, songToTrack, toast, shuffle]);
|
}, [api, songToTrack, toast, shuffle]);
|
||||||
const playAlbum = useCallback(async (albumId: string) => {
|
const playAlbum = useCallback(async (albumId: string) => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { album, songs } = await api.getAlbum(albumId);
|
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]);
|
}, [api, playTrack, songToTrack, toast, shuffle]);
|
||||||
|
|
||||||
const playAlbumFromTrack = useCallback(async (albumId: string, startingSongId: string) => {
|
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);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { album, songs } = await api.getAlbum(albumId);
|
const { album, songs } = await api.getAlbum(albumId);
|
||||||
@@ -392,6 +433,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [queue.length]);
|
}, [queue.length]);
|
||||||
|
|
||||||
const shuffleAllAlbums = useCallback(async () => {
|
const shuffleAllAlbums = useCallback(async () => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const albums = await api.getAlbums('alphabeticalByName', 500, 0);
|
const albums = await api.getAlbums('alphabeticalByName', 500, 0);
|
||||||
@@ -427,6 +477,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [api, songToTrack, toast]);
|
}, [api, songToTrack, toast]);
|
||||||
|
|
||||||
const playArtist = useCallback(async (artistId: string) => {
|
const playArtist = useCallback(async (artistId: string) => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { artist, albums } = await api.getArtist(artistId);
|
const { artist, albums } = await api.getArtist(artistId);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useTheme } from '@/app/components/ThemeProvider';
|
import { useTheme } from '@/app/components/ThemeProvider';
|
||||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
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 SettingsPage = () => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -24,6 +24,14 @@ const SettingsPage = () => {
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = 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) => {
|
const handleInputChange = (field: string, value: string) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-2xl">
|
<div className="container mx-auto p-6 max-w-2xl">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -210,6 +229,53 @@ const SettingsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>"Now Playing" 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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
|||||||
123
hooks/use-lastfm-scrobbler.ts
Normal file
123
hooks/use-lastfm-scrobbler.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
|
import { Track } from '@/app/components/AudioPlayerContext';
|
||||||
|
|
||||||
|
interface ScrobbleState {
|
||||||
|
trackId: string | null;
|
||||||
|
hasScrobbled: boolean;
|
||||||
|
hasUpdatedNowPlaying: boolean;
|
||||||
|
playStartTime: number;
|
||||||
|
lastPlayedDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLastFmScrobbler() {
|
||||||
|
const scrobbleStateRef = useRef<ScrobbleState>({
|
||||||
|
trackId: null,
|
||||||
|
hasScrobbled: false,
|
||||||
|
hasUpdatedNowPlaying: false,
|
||||||
|
playStartTime: 0,
|
||||||
|
lastPlayedDuration: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isScrobblingEnabled = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return localStorage.getItem('lastfm-scrobbling-enabled') !== 'false';
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNowPlaying = useCallback(async (track: Track) => {
|
||||||
|
if (!isScrobblingEnabled()) return;
|
||||||
|
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
if (!api || !track.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateNowPlaying(track.id);
|
||||||
|
scrobbleStateRef.current.hasUpdatedNowPlaying = true;
|
||||||
|
console.log('Updated now playing for Last.fm:', track.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update now playing:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 (!isScrobblingEnabled()) return;
|
||||||
|
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
if (!api || !track.id || 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 according to Last.fm guidelines
|
||||||
|
if (api.shouldScrobble(totalPlayedDuration, duration)) {
|
||||||
|
try {
|
||||||
|
await api.scrobbleTrack(track.id);
|
||||||
|
scrobbleStateRef.current.hasScrobbled = true;
|
||||||
|
console.log('Scrobbled track to Last.fm:', track.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to scrobble track:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTrackEnd = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||||
|
if (!isScrobblingEnabled()) return;
|
||||||
|
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
if (!api || !track.id) 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 && api.shouldScrobble(totalPlayedDuration, duration)) {
|
||||||
|
try {
|
||||||
|
await api.scrobbleTrack(track.id);
|
||||||
|
console.log('Final scrobble for completed track:', track.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to scrobble completed track:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onTrackStart,
|
||||||
|
onTrackPlay,
|
||||||
|
onTrackPause,
|
||||||
|
onTrackProgress,
|
||||||
|
onTrackEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export interface ArtistInfo {
|
|||||||
|
|
||||||
class NavidromeAPI {
|
class NavidromeAPI {
|
||||||
private config: NavidromeConfig;
|
private config: NavidromeConfig;
|
||||||
private clientName = 'stillnavidrome';
|
private clientName = 'miceclient';
|
||||||
private version = '1.16.0';
|
private version = '1.16.0';
|
||||||
|
|
||||||
constructor(config: NavidromeConfig) {
|
constructor(config: NavidromeConfig) {
|
||||||
@@ -341,6 +341,42 @@ class NavidromeAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced scrobbling functionality for Last.fm integration
|
||||||
|
async updateNowPlaying(songId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.makeRequest('scrobble', {
|
||||||
|
id: songId,
|
||||||
|
submission: 'false',
|
||||||
|
time: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update now playing:', error);
|
||||||
|
// Don't throw - this is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrobbleTrack(songId: string, timestamp?: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.makeRequest('scrobble', {
|
||||||
|
id: songId,
|
||||||
|
submission: 'true',
|
||||||
|
time: timestamp || Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to scrobble track:', error);
|
||||||
|
// Don't throw - this is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to determine if a track should be scrobbled
|
||||||
|
// According to Last.fm guidelines: track should be scrobbled if played for at least
|
||||||
|
// 30 seconds OR half the track duration, whichever comes first
|
||||||
|
shouldScrobble(playedDuration: number, totalDuration: number): boolean {
|
||||||
|
const minimumTime = 30; // 30 seconds minimum
|
||||||
|
const halfTrackTime = totalDuration / 2;
|
||||||
|
return playedDuration >= Math.min(minimumTime, halfTrackTime);
|
||||||
|
}
|
||||||
|
|
||||||
async getAllSongs(size = 500, offset = 0): Promise<Song[]> {
|
async getAllSongs(size = 500, offset = 0): Promise<Song[]> {
|
||||||
const response = await this.makeRequest('search3', {
|
const response = await this.makeRequest('search3', {
|
||||||
query: '',
|
query: '',
|
||||||
|
|||||||
2148
pnpm-lock.yaml
generated
2148
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user