reimplement player scrobble

This commit is contained in:
jeffvli
2025-11-23 15:06:37 -08:00
parent c23e459b89
commit af7e52295a
4 changed files with 238 additions and 202 deletions
@@ -6,7 +6,6 @@ import styles from './synchronized-lyrics.module.css';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { useLyricsSettings, usePlaybackType, usePlayerActions } from '/@/renderer/store'; import { useLyricsSettings, usePlaybackType, usePlayerActions } from '/@/renderer/store';
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types';
@@ -31,7 +30,6 @@ export const SynchronizedLyrics = ({
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const settings = useLyricsSettings(); const settings = useLyricsSettings();
const { mediaSeekToTimestamp } = usePlayerActions(); const { mediaSeekToTimestamp } = usePlayerActions();
const { handleScrobbleFromSeek } = useScrobble();
// State for player status and timestamp from events // State for player status and timestamp from events
const [status, setStatus] = useState<PlayerStatus>(PlayerStatus.PAUSED); const [status, setStatus] = useState<PlayerStatus>(PlayerStatus.PAUSED);
@@ -42,12 +40,11 @@ export const SynchronizedLyrics = ({
if (playbackType === PlayerType.LOCAL && mpvPlayer) { if (playbackType === PlayerType.LOCAL && mpvPlayer) {
mpvPlayer.seekTo(time); mpvPlayer.seekTo(time);
} else { } else {
handleScrobbleFromSeek(time);
mpris?.updateSeek(time); mpris?.updateSeek(time);
mediaSeekToTimestamp(time); mediaSeekToTimestamp(time);
} }
}, },
[handleScrobbleFromSeek, mediaSeekToTimestamp, playbackType], [mediaSeekToTimestamp, playbackType],
); );
// const seeked = useSeeked(); // const seeked = useSeeked();
@@ -4,6 +4,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player'; import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player'; import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { import {
updateQueueFavorites, updateQueueFavorites,
updateQueueRatings, updateQueueRatings,
@@ -17,6 +18,8 @@ export const AudioPlayers = () => {
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
useScrobble();
// Listen to favorite and rating events to update queue songs // Listen to favorite and rating events to update queue songs
useEffect(() => { useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
+219 -198
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store'; import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
import { QueueSong, ServerType } from '/@/shared/types/domain-types'; import { QueueSong, ServerType } from '/@/shared/types/domain-types';
@@ -11,6 +12,10 @@ import { PlayerStatus } from '/@/shared/types/types';
- If the song has been played for the required duration - If the song has been played for the required duration
Scrobble Events: Scrobble Events:
- On song timestamp update:
- If the song has been played for the required percentage
- If the song has been played for the required duration
- When the song changes (or is completed): - When the song changes (or is completed):
- Current song: Sends the 'playing' scrobble event - Current song: Sends the 'playing' scrobble event
- Previous song (if exists): Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false - Previous song (if exists): Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
@@ -28,15 +33,12 @@ Scrobble Events:
- Sends the 'timeupdate' scrobble event (Jellyfin only) - Sends the 'timeupdate' scrobble event (Jellyfin only)
Progress Events (Jellyfin only): Progress Events:
- When the song is playing: - When the song is playing (Jellyfin only):
- Sends the 'progress' scrobble event on an interval - Sends the 'progress' scrobble event on an interval
*/ */
type PlayerEvent = [PlayerStatus, number];
type SongEvent = [QueueSong | undefined, number, 1 | 2];
const checkScrobbleConditions = (args: { const checkScrobbleConditions = (args: {
scrobbleAtDurationMs: number; scrobbleAtDurationMs: number;
scrobbleAtPercentage: number; scrobbleAtPercentage: number;
@@ -62,49 +64,149 @@ export const useScrobble = () => {
const sendScrobble = useSendScrobble(); const sendScrobble = useSendScrobble();
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false); const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const lastProgressEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSeek = useCallback( const handleScrobbleFromProgress = useCallback(
(currentTime: number) => { (properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song; console.log('handleScrobbleFromProgress', properties, prev);
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return; const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status;
const position = if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
sendScrobble.mutate({ const currentTime = properties.timestamp;
apiClientProps: { serverId: currentSong?.serverId || '' }, const previousTime = prev.timestamp;
query: {
event: 'timeupdate', // Detect song restart: timestamp resets to 0 (or goes backwards significantly) while song stays the same
id: currentSong.id, // This happens when pressing "Previous Track" and the song restarts (if >= 10 seconds)
position, if (
submission: false, currentTime < previousTime &&
}, currentTime < 5 && // Reset to near 0
}); previousTime >= 10 && // Was playing for at least 10 seconds
currentSong._uniqueId === previousSongRef.current?._uniqueId
) {
// Handle song restart scrobble
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: previousTime * 1000,
songDurationMs: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
const position =
currentSong._serverType === ServerType.JELLYFIN
? previousTime * 1e7
: undefined;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
}
// Send start event for Jellyfin on restart
if (currentSong._serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
}
setIsCurrentSongScrobbled(false);
lastProgressEventRef.current = 0;
previousTimestampRef.current = 0;
return;
}
// Send Jellyfin progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
});
lastProgressEventRef.current = currentTime;
}
}
// Check if we should submit scrobble based on conditions
if (!isCurrentSongScrobbled) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: currentTime * 1000,
songDurationMs: currentSong.duration,
});
if (shouldSubmitScrobble) {
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentTime * 1e7
: undefined;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
setIsCurrentSongScrobbled(true);
}
}
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
); );
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSongChange = useCallback( const handleScrobbleFromSongChange = useCallback(
(current: SongEvent, previous: SongEvent) => { (
if (scrobbleSettings?.notify && current[0]?.id) { properties: { index: number; song: QueueSong | undefined },
clearTimeout(notifyTimeoutId.current); prev: { index: number; song: QueueSong | undefined },
const currentSong = current[0]; ) => {
const currentSong = properties.song;
const previousSong = previousSongRef.current;
const previousTimestamp = previousTimestampRef.current;
// Set a delay so that quickly (within a second) switching songs doesn't trigger multiple // Handle notifications
// notifications if (scrobbleSettings?.notify && currentSong?.id) {
notifyTimeoutId.current = setTimeout(() => { clearTimeout(notifyTimeoutRef.current);
// Only trigger if the song changed, or the player changed. This should be the case notifyTimeoutRef.current = setTimeout(() => {
// anyways, but who knows
if ( if (
currentSong._uniqueId !== previous[0]?._uniqueId || currentSong._uniqueId !== previousSong?._uniqueId ||
current[2] !== previous[2] properties.index !== prev.index
) { ) {
const artists = const artists =
currentSong.artists?.length > 0 currentSong.artists?.length > 0
@@ -120,36 +222,32 @@ export const useScrobble = () => {
}, 1000); }, 1000);
} }
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) {
previousSongRef.current = currentSong;
if (progressIntervalId.current) { previousTimestampRef.current = 0;
clearInterval(progressIntervalId.current); return;
progressIntervalId.current = null;
} }
const previousSong = previous[0]; // Send completion scrobble for previous song
const previousSongTimeSec = previous[1];
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) { if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({ const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: previousSongTimeSec * 1000, songCompletedDurationMs: previousTimestamp * 1000,
songDurationMs: previousSong.duration, songDurationMs: previousSong.duration,
}); });
if ( if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) || (!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?._serverType === ServerType.JELLYFIN previousSong._serverType === ServerType.JELLYFIN
) { ) {
const position = const position =
previousSong?._serverType === ServerType.JELLYFIN previousSong._serverType === ServerType.JELLYFIN
? previousSongTimeSec * 1e7 ? previousTimestamp * 1e7
: undefined; : undefined;
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: previousSong?._serverId || '' }, apiClientProps: { serverId: previousSong._serverId || '' },
query: { query: {
id: previousSong.id, id: previousSong.id,
position, position,
@@ -160,20 +258,17 @@ export const useScrobble = () => {
} }
setIsCurrentSongScrobbled(false); setIsCurrentSongScrobbled(false);
lastProgressEventRef.current = 0;
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly // Use a timeout to prevent spamming the server when switching songs quickly
clearTimeout(songChangeTimeoutId.current); clearTimeout(songChangeTimeoutRef.current);
songChangeTimeoutId.current = setTimeout(() => { songChangeTimeoutRef.current = setTimeout(() => {
const currentSong = current[0]; const currentStatus = usePlayerStore.getState().player.status;
// Get the current status from the state, not variable. This is because
// of a timing issue where, when playing the first track, the first
// event is song, and then the event is play
const currentStatus = usePlayerStore.getState().current.status;
// Send start scrobble when song changes and the new song is playing // Send start scrobble when song changes and the new song is playing
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) { if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: currentSong?._serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
@@ -181,21 +276,11 @@ export const useScrobble = () => {
submission: false, submission: false,
}, },
}); });
if (currentSong?._serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
} }
}, 2000); }, 2000);
previousSongRef.current = currentSong;
previousTimestampRef.current = 0;
}, },
[ [
scrobbleSettings?.notify, scrobbleSettings?.notify,
@@ -205,30 +290,31 @@ export const useScrobble = () => {
isPrivateModeEnabled, isPrivateModeEnabled,
isCurrentSongScrobbled, isCurrentSongScrobbled,
sendScrobble, sendScrobble,
handleScrobbleFromSeek,
], ],
); );
const handleScrobbleFromStatusChange = useCallback( const handleScrobbleFromStatusChange = useCallback(
(current: PlayerEvent, previous: PlayerEvent) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
(properties: { status: PlayerStatus }, _prev: { status: PlayerStatus }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song; const currentSong = usePlayerStore.getState().getCurrentSong();
const currentTimestamp =
usePlayerStore.getState().player.index >= 0 ? previousTimestampRef.current : 0;
if (!currentSong?.id) return; if (!currentSong?.id) return;
const position = const position =
currentSong?.serverType === ServerType.JELLYFIN currentSong._serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7 ? currentTimestamp * 1e7
: undefined; : undefined;
const currentStatus = current[0]; const currentStatus = properties.status;
const currentTimeSec = current[1];
// Whenever the player is restarted, send a 'start' scrobble
if (currentStatus === PlayerStatus.PLAYING) { if (currentStatus === PlayerStatus.PLAYING) {
// Send unpause event
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
event: 'unpause', event: 'unpause',
id: currentSong.id, id: currentSong.id,
@@ -236,24 +322,10 @@ export const useScrobble = () => {
submission: false, submission: false,
}, },
}); });
} else if (currentSong._serverType === ServerType.JELLYFIN) {
if (currentSong?.serverType === ServerType.JELLYFIN) { // Send pause event for Jellyfin
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
event: 'pause', event: 'pause',
id: currentSong.id, id: currentSong.id,
@@ -261,33 +333,27 @@ export const useScrobble = () => {
submission: false, submission: false,
}, },
}); });
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
} else { } else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack(); // For non-Jellyfin servers, check scrobble conditions on pause
const isFisrtTrackInQueue = usePlayerStore.getState().actions.checkIsFirstTrack(); const isLastTrackInQueue = usePlayerStore.getState().isLastTrackInQueue();
const previousTimeSec = previous[1]; const isFirstTrackInQueue = usePlayerStore.getState().isFirstTrackInQueue();
const previousTimestamp = previousTimestampRef.current;
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({ const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
// If scrobbling the last song in the queue, use the previous time instead of the current time since otherwise time value will be 0 // If scrobbling the last song in the queue, use the previous time
// Note that if the queue has one item (both first and last), use the elapsed time, as this will otherwise result // Note: if queue has one item (both first and last), use current time
// in duplicate scrobbles in tandem with handleScrobbleFromSongChange
songCompletedDurationMs: songCompletedDurationMs:
(isLastTrackInQueue && !isFisrtTrackInQueue (isLastTrackInQueue && !isFirstTrackInQueue
? previousTimeSec ? previousTimestamp
: currentTimeSec) * 1000, : currentTimestamp) * 1000,
songDurationMs: currentSong.duration, songDurationMs: currentSong.duration,
}); });
if (!isCurrentSongScrobbled && shouldSubmitScrobble) { if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
id: currentSong.id, id: currentSong.id,
submission: true, submission: true,
@@ -301,103 +367,58 @@ export const useScrobble = () => {
[ [
isScrobbleEnabled, isScrobbleEnabled,
isPrivateModeEnabled, isPrivateModeEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage, scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled, isCurrentSongScrobbled,
sendScrobble,
], ],
); );
// When pressing the "Previous Track" button, the player will restart the current song if the const handleScrobbleFromSeek = useCallback(
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will // eslint-disable-next-line @typescript-eslint/no-unused-vars
// need to perform another check to see if the scrobble conditions are met (properties: { timestamp: number }, _prev: { timestamp: number }) => {
const handleScrobbleFromSongRestart = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song; const currentSong = usePlayerStore.getState().getCurrentSong();
if (!currentSong?.id) return; if (!currentSong?.id || currentSong._serverType !== ServerType.JELLYFIN) return;
const position = const position = properties.timestamp * 1e7;
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({ sendScrobble.mutate({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, apiClientProps: { serverId: currentSong._serverId || '' },
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, query: {
songCompletedDurationMs: currentTime, event: 'timeupdate',
songDurationMs: currentSong.duration, id: currentSong.id,
position,
submission: false,
},
}); });
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
}
setIsCurrentSongScrobbled(false);
}, },
[ [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
); );
useEffect(() => { // Update previous timestamp on progress for use in status change handler
const unsubSongChange = usePlayerStore.subscribe( const handleProgressUpdate = useCallback(
(state): SongEvent => [state.current.song, state.current.time, state.current.player], (properties: { timestamp: number }, prev: { timestamp: number }) => {
previousTimestampRef.current = properties.timestamp;
handleScrobbleFromProgress(properties, prev);
},
[handleScrobbleFromProgress],
);
usePlayerEvents(
{
onCurrentSongChange: handleScrobbleFromSongChange,
onPlayerProgress: handleProgressUpdate,
onPlayerSeekToTimestamp: handleScrobbleFromSeek,
onPlayerStatus: handleScrobbleFromStatusChange,
},
[
handleScrobbleFromSongChange, handleScrobbleFromSongChange,
{ handleProgressUpdate,
// We need the current time to check the scrobble condition, but we only want to handleScrobbleFromSeek,
// trigger the callback when the song changes
// There are two conditions where this should trigger:
// 1. The song actually changes (the common case)
// 2. The song does not change, but the player dows. This would either be
// a single track on repeat one, or one track added to the queue
// multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) =>
// compute whether the song changed
a[0]?._uniqueId === b[0]?._uniqueId &&
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2],
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state): PlayerEvent => [state.current.status, state.current.time],
handleScrobbleFromStatusChange, handleScrobbleFromStatusChange,
{ ],
equalityFn: (a, b) => a[0] === b[0], );
},
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
}; };
+15
View File
@@ -41,6 +41,8 @@ interface Actions {
items: QueueSong[]; items: QueueSong[];
}; };
increaseVolume: (value: number) => void; increaseVolume: (value: number) => void;
isFirstTrackInQueue: () => boolean;
isLastTrackInQueue: () => boolean;
mediaAutoNext: () => PlayerData; mediaAutoNext: () => PlayerData;
mediaNext: () => void; mediaNext: () => void;
mediaPause: () => void; mediaPause: () => void;
@@ -606,6 +608,17 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.volume = Math.min(100, state.player.volume + value); state.player.volume = Math.min(100, state.player.volume + value);
}); });
}, },
isFirstTrackInQueue: () => {
const state = get();
const currentIndex = state.player.index;
return currentIndex === 0;
},
isLastTrackInQueue: () => {
const state = get();
const queue = state.getQueueOrder();
const currentIndex = state.player.index;
return currentIndex === queue.items.length - 1;
},
mediaAutoNext: () => { mediaAutoNext: () => {
const currentIndex = get().player.index; const currentIndex = get().player.index;
const player = get().player; const player = get().player;
@@ -1278,6 +1291,8 @@ export const usePlayerActions = () => {
decreaseVolume: state.decreaseVolume, decreaseVolume: state.decreaseVolume,
getQueue: state.getQueue, getQueue: state.getQueue,
increaseVolume: state.increaseVolume, increaseVolume: state.increaseVolume,
isFirstTrackInQueue: state.isFirstTrackInQueue,
isLastTrackInQueue: state.isLastTrackInQueue,
mediaAutoNext: state.mediaAutoNext, mediaAutoNext: state.mediaAutoNext,
mediaNext: state.mediaNext, mediaNext: state.mediaNext,
mediaPause: state.mediaPause, mediaPause: state.mediaPause,