mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-16 16:34:24 +02:00
reimplement player scrobble
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user