2025-05-18 14:03:18 -07:00
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
|
2023-01-30 20:01:57 -08:00
|
|
|
import { QueueSong, ServerType } from '/@/renderer/api/types';
|
|
|
|
|
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
2024-04-01 22:13:06 -07:00
|
|
|
import { usePlayerStore } from '/@/renderer/store';
|
2023-03-30 06:44:33 -07:00
|
|
|
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
|
2023-01-30 20:01:57 -08:00
|
|
|
import { PlayerStatus } from '/@/renderer/types';
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
Scrobble Conditions (match any):
|
|
|
|
|
- If the song has been played for the required percentage
|
|
|
|
|
- If the song has been played for the required duration
|
|
|
|
|
|
|
|
|
|
Scrobble Events:
|
|
|
|
|
- When the song changes (or is completed):
|
|
|
|
|
- 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
|
|
|
|
|
- Resets the 'isCurrentSongScrobbled' state to false
|
|
|
|
|
|
|
|
|
|
- When the song is paused:
|
|
|
|
|
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
|
|
|
|
|
- Sends the 'pause' scrobble event (Jellyfin only)
|
|
|
|
|
|
|
|
|
|
- When the song is restarted:
|
|
|
|
|
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
|
|
|
|
|
- Resets the 'isCurrentSongScrobbled' state to false
|
|
|
|
|
|
|
|
|
|
- When the song is seeked:
|
|
|
|
|
- Sends the 'timeupdate' scrobble event (Jellyfin only)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Progress Events (Jellyfin only):
|
|
|
|
|
- When the song is playing:
|
|
|
|
|
- Sends the 'progress' scrobble event on an interval
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const checkScrobbleConditions = (args: {
|
2023-10-05 22:11:48 -07:00
|
|
|
scrobbleAtDurationMs: number;
|
2023-07-01 19:10:05 -07:00
|
|
|
scrobbleAtPercentage: number;
|
2023-10-06 04:45:47 +00:00
|
|
|
songCompletedDurationMs: number;
|
|
|
|
|
songDurationMs: number;
|
2023-01-30 20:01:57 -08:00
|
|
|
}) => {
|
2023-10-05 22:11:48 -07:00
|
|
|
const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } =
|
2023-10-06 04:45:47 +00:00
|
|
|
args;
|
|
|
|
|
const percentageOfSongCompleted = songDurationMs
|
|
|
|
|
? (songCompletedDurationMs / songDurationMs) * 100
|
2023-07-01 19:10:05 -07:00
|
|
|
: 0;
|
|
|
|
|
|
2023-10-05 22:11:48 -07:00
|
|
|
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
|
|
|
|
|
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
|
|
|
|
|
|
|
|
|
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
|
2023-01-30 20:01:57 -08:00
|
|
|
};
|
2022-12-19 15:59:14 -08:00
|
|
|
|
|
|
|
|
export const useScrobble = () => {
|
2023-07-01 19:10:05 -07:00
|
|
|
const scrobbleSettings = usePlaybackSettings().scrobble;
|
|
|
|
|
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
|
|
|
|
const sendScrobble = useSendScrobble();
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
const handleScrobbleFromSeek = useCallback(
|
|
|
|
|
(currentTime: number) => {
|
|
|
|
|
if (!isScrobbleEnabled) return;
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
const currentSong = usePlayerStore.getState().current.song;
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
const position =
|
|
|
|
|
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
2023-01-30 20:01:57 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
event: 'timeupdate',
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position,
|
|
|
|
|
submission: false,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
2023-01-30 20:01:57 -08:00
|
|
|
},
|
2023-07-01 19:10:05 -07:00
|
|
|
[isScrobbleEnabled, sendScrobble],
|
2023-01-30 20:01:57 -08:00
|
|
|
);
|
2022-12-19 15:59:14 -08:00
|
|
|
|
2025-05-18 14:03:18 -07:00
|
|
|
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
|
|
|
|
|
const songChangeTimeoutId = useRef<null | ReturnType<typeof setTimeout>>(null);
|
2023-07-01 19:10:05 -07:00
|
|
|
const handleScrobbleFromSongChange = useCallback(
|
|
|
|
|
(
|
2025-05-18 14:03:18 -07:00
|
|
|
current: (number | QueueSong | undefined)[],
|
|
|
|
|
previous: (number | QueueSong | undefined)[],
|
2023-07-01 19:10:05 -07:00
|
|
|
) => {
|
|
|
|
|
if (!isScrobbleEnabled) return;
|
|
|
|
|
|
|
|
|
|
if (progressIntervalId.current) {
|
|
|
|
|
clearInterval(progressIntervalId.current);
|
2024-04-01 22:13:06 -07:00
|
|
|
progressIntervalId.current = null;
|
2023-07-01 19:10:05 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// const currentSong = current[0] as QueueSong | undefined;
|
|
|
|
|
const previousSong = previous[0] as QueueSong;
|
2023-10-06 04:45:47 +00:00
|
|
|
const previousSongTimeSec = previous[1] as number;
|
2023-07-01 19:10:05 -07:00
|
|
|
|
|
|
|
|
// Send completion scrobble when song changes and a previous song exists
|
|
|
|
|
if (previousSong?.id) {
|
|
|
|
|
const shouldSubmitScrobble = checkScrobbleConditions({
|
2023-10-05 22:11:48 -07:00
|
|
|
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
2023-07-01 19:10:05 -07:00
|
|
|
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
2023-10-06 04:45:47 +00:00
|
|
|
songCompletedDurationMs: previousSongTimeSec * 1000,
|
|
|
|
|
songDurationMs: previousSong.duration,
|
2023-07-01 19:10:05 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
|
|
|
|
|
previousSong?.serverType === ServerType.JELLYFIN
|
|
|
|
|
) {
|
|
|
|
|
const position =
|
|
|
|
|
previousSong?.serverType === ServerType.JELLYFIN
|
2023-10-06 04:45:47 +00:00
|
|
|
? previousSongTimeSec * 1e7
|
2023-07-01 19:10:05 -07:00
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
id: previousSong.id,
|
|
|
|
|
position,
|
|
|
|
|
submission: true,
|
|
|
|
|
},
|
|
|
|
|
serverId: previousSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsCurrentSongScrobbled(false);
|
|
|
|
|
|
|
|
|
|
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
|
|
|
|
|
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
|
|
|
|
|
songChangeTimeoutId.current = setTimeout(() => {
|
|
|
|
|
const currentSong = current[0] as QueueSong | undefined;
|
2024-04-01 22:13:06 -07:00
|
|
|
// 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;
|
2023-07-01 19:10:05 -07:00
|
|
|
|
|
|
|
|
// Send start scrobble when song changes and the new song is playing
|
2024-04-01 22:13:06 -07:00
|
|
|
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
|
2023-07-01 19:10:05 -07:00
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
event: 'start',
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position: 0,
|
|
|
|
|
submission: false,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
2024-04-01 22:13:06 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
progressIntervalId.current = setInterval(() => {
|
|
|
|
|
const currentTime = usePlayerStore.getState().current.time;
|
|
|
|
|
handleScrobbleFromSeek(currentTime);
|
|
|
|
|
}, 10000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 2000);
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
isScrobbleEnabled,
|
|
|
|
|
scrobbleSettings?.scrobbleAtDuration,
|
|
|
|
|
scrobbleSettings?.scrobbleAtPercentage,
|
|
|
|
|
isCurrentSongScrobbled,
|
|
|
|
|
sendScrobble,
|
|
|
|
|
handleScrobbleFromSeek,
|
|
|
|
|
],
|
2023-01-30 20:01:57 -08:00
|
|
|
);
|
2022-12-19 15:59:14 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
const handleScrobbleFromStatusChange = useCallback(
|
2023-10-06 04:45:47 +00:00
|
|
|
(
|
2025-05-18 14:03:18 -07:00
|
|
|
current: (number | PlayerStatus | undefined)[],
|
|
|
|
|
previous: (number | PlayerStatus | undefined)[],
|
2023-10-06 04:45:47 +00:00
|
|
|
) => {
|
2023-07-01 19:10:05 -07:00
|
|
|
if (!isScrobbleEnabled) return;
|
|
|
|
|
|
|
|
|
|
const currentSong = usePlayerStore.getState().current.song;
|
|
|
|
|
|
|
|
|
|
if (!currentSong?.id) return;
|
|
|
|
|
|
|
|
|
|
const position =
|
|
|
|
|
currentSong?.serverType === ServerType.JELLYFIN
|
|
|
|
|
? usePlayerStore.getState().current.time * 1e7
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2023-10-06 04:45:47 +00:00
|
|
|
const currentStatus = current[0] as PlayerStatus;
|
|
|
|
|
const currentTimeSec = current[1] as number;
|
|
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
// Whenever the player is restarted, send a 'start' scrobble
|
2023-10-06 04:45:47 +00:00
|
|
|
if (currentStatus === PlayerStatus.PLAYING) {
|
2023-07-01 19:10:05 -07:00
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
event: 'unpause',
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position,
|
|
|
|
|
submission: false,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
2024-04-01 22:13:06 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
progressIntervalId.current = setInterval(() => {
|
2024-04-01 22:13:06 -07:00
|
|
|
const currentTime = usePlayerStore.getState().current.time;
|
2023-07-01 19:10:05 -07:00
|
|
|
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({
|
|
|
|
|
query: {
|
|
|
|
|
event: 'pause',
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position,
|
|
|
|
|
submission: false,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (progressIntervalId.current) {
|
|
|
|
|
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
2024-04-01 22:13:06 -07:00
|
|
|
progressIntervalId.current = null;
|
2023-07-01 19:10:05 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
2023-10-06 04:45:47 +00:00
|
|
|
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
|
|
|
|
|
const previousTimeSec = previous[1] as number;
|
|
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
// If not already scrobbled, send a 'submission' scrobble if conditions are met
|
|
|
|
|
const shouldSubmitScrobble = checkScrobbleConditions({
|
2023-10-05 22:11:48 -07:00
|
|
|
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
2023-07-01 19:10:05 -07:00
|
|
|
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
2023-10-06 04:45:47 +00:00
|
|
|
// If scrobbling the last song in the queue, use the previous time instead of the current time since otherwise time value will be 0
|
|
|
|
|
songCompletedDurationMs:
|
|
|
|
|
(isLastTrackInQueue ? previousTimeSec : currentTimeSec) * 1000,
|
|
|
|
|
songDurationMs: currentSong.duration,
|
2023-07-01 19:10:05 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
|
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
submission: true,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setIsCurrentSongScrobbled(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
isScrobbleEnabled,
|
|
|
|
|
sendScrobble,
|
|
|
|
|
handleScrobbleFromSeek,
|
|
|
|
|
scrobbleSettings?.scrobbleAtDuration,
|
|
|
|
|
scrobbleSettings?.scrobbleAtPercentage,
|
|
|
|
|
isCurrentSongScrobbled,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// When pressing the "Previous Track" button, the player will restart the current song if the
|
|
|
|
|
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
|
|
|
|
|
// need to perform another check to see if the scrobble conditions are met
|
|
|
|
|
const handleScrobbleFromSongRestart = useCallback(
|
|
|
|
|
(currentTime: number) => {
|
|
|
|
|
if (!isScrobbleEnabled) return;
|
|
|
|
|
|
|
|
|
|
const currentSong = usePlayerStore.getState().current.song;
|
|
|
|
|
|
|
|
|
|
if (!currentSong?.id) return;
|
|
|
|
|
|
|
|
|
|
const position =
|
|
|
|
|
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
|
|
|
|
|
|
|
|
|
const shouldSubmitScrobble = checkScrobbleConditions({
|
2023-10-05 22:11:48 -07:00
|
|
|
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
2023-07-01 19:10:05 -07:00
|
|
|
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
2023-10-06 04:45:47 +00:00
|
|
|
songCompletedDurationMs: currentTime,
|
|
|
|
|
songDurationMs: currentSong.duration,
|
2023-07-01 19:10:05 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
|
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position,
|
|
|
|
|
submission: true,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
|
|
|
|
sendScrobble.mutate({
|
|
|
|
|
query: {
|
|
|
|
|
event: 'start',
|
|
|
|
|
id: currentSong.id,
|
|
|
|
|
position: 0,
|
|
|
|
|
submission: false,
|
|
|
|
|
},
|
|
|
|
|
serverId: currentSong?.serverId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsCurrentSongScrobbled(false);
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
isScrobbleEnabled,
|
|
|
|
|
scrobbleSettings?.scrobbleAtDuration,
|
|
|
|
|
scrobbleSettings?.scrobbleAtPercentage,
|
|
|
|
|
isCurrentSongScrobbled,
|
|
|
|
|
sendScrobble,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const unsubSongChange = usePlayerStore.subscribe(
|
2024-02-01 04:12:39 +00:00
|
|
|
(state) => [state.current.song, state.current.time, state.current.player],
|
2023-07-01 19:10:05 -07:00
|
|
|
handleScrobbleFromSongChange,
|
|
|
|
|
{
|
|
|
|
|
// We need the current time to check the scrobble condition, but we only want to
|
|
|
|
|
// trigger the callback when the song changes
|
2024-02-01 04:12:39 +00:00
|
|
|
// 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) =>
|
2025-04-20 10:54:44 -07:00
|
|
|
// compute whether the song changed
|
2024-02-01 04:12:39 +00:00
|
|
|
(a[0] as QueueSong)?.uniqueId === (b[0] as QueueSong)?.uniqueId &&
|
2025-04-20 10:54:44 -07:00
|
|
|
// compute whether the position changed. This should imply 1
|
|
|
|
|
a[2] === b[2] &&
|
|
|
|
|
// compute whether the same player: relevant for repeat one and repeat all (one track)
|
|
|
|
|
a[3] === b[3],
|
2023-07-01 19:10:05 -07:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const unsubStatusChange = usePlayerStore.subscribe(
|
2023-10-06 04:45:47 +00:00
|
|
|
(state) => [state.current.status, state.current.time],
|
2023-07-01 19:10:05 -07:00
|
|
|
handleScrobbleFromStatusChange,
|
2023-10-06 04:45:47 +00:00
|
|
|
{
|
|
|
|
|
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
|
|
|
|
|
},
|
2023-07-01 19:10:05 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
unsubSongChange();
|
|
|
|
|
unsubStatusChange();
|
|
|
|
|
};
|
|
|
|
|
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
|
2022-12-19 15:59:14 -08:00
|
|
|
|
2023-07-01 19:10:05 -07:00
|
|
|
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
|
2022-12-19 15:59:14 -08:00
|
|
|
};
|