diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 7ff146998..f702b4cb2 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -6,7 +6,6 @@ import styles from './synchronized-lyrics.module.css'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; 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 { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types'; @@ -31,7 +30,6 @@ export const SynchronizedLyrics = ({ const playbackType = usePlaybackType(); const settings = useLyricsSettings(); const { mediaSeekToTimestamp } = usePlayerActions(); - const { handleScrobbleFromSeek } = useScrobble(); // State for player status and timestamp from events const [status, setStatus] = useState(PlayerStatus.PAUSED); @@ -42,12 +40,11 @@ export const SynchronizedLyrics = ({ if (playbackType === PlayerType.LOCAL && mpvPlayer) { mpvPlayer.seekTo(time); } else { - handleScrobbleFromSeek(time); mpris?.updateSeek(time); mediaSeekToTimestamp(time); } }, - [handleScrobbleFromSeek, mediaSeekToTimestamp, playbackType], + [mediaSeekToTimestamp, playbackType], ); // const seeked = useSeeked(); diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 95ab0fb96..7ce26ed41 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -4,6 +4,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player'; import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player'; +import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { updateQueueFavorites, updateQueueRatings, @@ -17,6 +18,8 @@ export const AudioPlayers = () => { const playbackType = usePlaybackType(); const serverId = useCurrentServerId(); + useScrobble(); + // Listen to favorite and rating events to update queue songs useEffect(() => { const handleFavorite = (payload: UserFavoriteEventPayload) => { diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index a2b8b14c5..5d456b6c4 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -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 { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store'; 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 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): - 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 @@ -28,15 +33,12 @@ Scrobble Events: - Sends the 'timeupdate' scrobble event (Jellyfin only) -Progress Events (Jellyfin only): - - When the song is playing: +Progress Events: + - When the song is playing (Jellyfin only): - Sends the 'progress' scrobble event on an interval + */ -type PlayerEvent = [PlayerStatus, number]; - -type SongEvent = [QueueSong | undefined, number, 1 | 2]; - const checkScrobbleConditions = (args: { scrobbleAtDurationMs: number; scrobbleAtPercentage: number; @@ -62,49 +64,149 @@ export const useScrobble = () => { const sendScrobble = useSendScrobble(); const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false); + const previousSongRef = useRef(undefined); + const previousTimestampRef = useRef(0); + const lastProgressEventRef = useRef(0); + const songChangeTimeoutRef = useRef | undefined>(undefined); + const notifyTimeoutRef = useRef | undefined>(undefined); - const handleScrobbleFromSeek = useCallback( - (currentTime: number) => { + const handleScrobbleFromProgress = useCallback( + (properties: { timestamp: number }, prev: { timestamp: number }) => { 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 = - currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined; + if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return; - sendScrobble.mutate({ - apiClientProps: { serverId: currentSong?.serverId || '' }, - query: { - event: 'timeupdate', - id: currentSong.id, - position, - submission: false, - }, - }); + const currentTime = properties.timestamp; + const previousTime = prev.timestamp; + + // Detect song restart: timestamp resets to 0 (or goes backwards significantly) while song stays the same + // This happens when pressing "Previous Track" and the song restarts (if >= 10 seconds) + if ( + 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); - const songChangeTimeoutId = useRef | undefined>(undefined); - const notifyTimeoutId = useRef | undefined>(undefined); - const handleScrobbleFromSongChange = useCallback( - (current: SongEvent, previous: SongEvent) => { - if (scrobbleSettings?.notify && current[0]?.id) { - clearTimeout(notifyTimeoutId.current); - const currentSong = current[0]; + ( + properties: { index: number; song: QueueSong | undefined }, + prev: { index: number; song: QueueSong | undefined }, + ) => { + 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 - // notifications - notifyTimeoutId.current = setTimeout(() => { - // Only trigger if the song changed, or the player changed. This should be the case - // anyways, but who knows + // Handle notifications + if (scrobbleSettings?.notify && currentSong?.id) { + clearTimeout(notifyTimeoutRef.current); + notifyTimeoutRef.current = setTimeout(() => { if ( - currentSong._uniqueId !== previous[0]?._uniqueId || - current[2] !== previous[2] + currentSong._uniqueId !== previousSong?._uniqueId || + properties.index !== prev.index ) { const artists = currentSong.artists?.length > 0 @@ -120,36 +222,32 @@ export const useScrobble = () => { }, 1000); } - if (!isScrobbleEnabled || isPrivateModeEnabled) return; - - if (progressIntervalId.current) { - clearInterval(progressIntervalId.current); - progressIntervalId.current = null; + if (!isScrobbleEnabled || isPrivateModeEnabled) { + previousSongRef.current = currentSong; + previousTimestampRef.current = 0; + return; } - const previousSong = previous[0]; - const previousSongTimeSec = previous[1]; - - // Send completion scrobble when song changes and a previous song exists + // Send completion scrobble for previous song if (previousSong?.id) { const shouldSubmitScrobble = checkScrobbleConditions({ scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, - songCompletedDurationMs: previousSongTimeSec * 1000, + songCompletedDurationMs: previousTimestamp * 1000, songDurationMs: previousSong.duration, }); if ( (!isCurrentSongScrobbled && shouldSubmitScrobble) || - previousSong?._serverType === ServerType.JELLYFIN + previousSong._serverType === ServerType.JELLYFIN ) { const position = - previousSong?._serverType === ServerType.JELLYFIN - ? previousSongTimeSec * 1e7 + previousSong._serverType === ServerType.JELLYFIN + ? previousTimestamp * 1e7 : undefined; sendScrobble.mutate({ - apiClientProps: { serverId: previousSong?._serverId || '' }, + apiClientProps: { serverId: previousSong._serverId || '' }, query: { id: previousSong.id, position, @@ -160,20 +258,17 @@ export const useScrobble = () => { } setIsCurrentSongScrobbled(false); + lastProgressEventRef.current = 0; - // Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly - clearTimeout(songChangeTimeoutId.current); - songChangeTimeoutId.current = setTimeout(() => { - const currentSong = current[0]; - // 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; + // Use a timeout to prevent spamming the server when switching songs quickly + clearTimeout(songChangeTimeoutRef.current); + songChangeTimeoutRef.current = setTimeout(() => { + const currentStatus = usePlayerStore.getState().player.status; // Send start scrobble when song changes and the new song is playing if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) { sendScrobble.mutate({ - apiClientProps: { serverId: currentSong?._serverId || '' }, + apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'start', id: currentSong.id, @@ -181,21 +276,11 @@ export const useScrobble = () => { 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); + + previousSongRef.current = currentSong; + previousTimestampRef.current = 0; }, [ scrobbleSettings?.notify, @@ -205,30 +290,31 @@ export const useScrobble = () => { isPrivateModeEnabled, isCurrentSongScrobbled, sendScrobble, - handleScrobbleFromSeek, ], ); 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; - 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; const position = - currentSong?.serverType === ServerType.JELLYFIN - ? usePlayerStore.getState().current.time * 1e7 + currentSong._serverType === ServerType.JELLYFIN + ? currentTimestamp * 1e7 : undefined; - const currentStatus = current[0]; - const currentTimeSec = current[1]; + const currentStatus = properties.status; - // Whenever the player is restarted, send a 'start' scrobble if (currentStatus === PlayerStatus.PLAYING) { + // Send unpause event sendScrobble.mutate({ - apiClientProps: { serverId: currentSong?.serverId || '' }, + apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'unpause', id: currentSong.id, @@ -236,24 +322,10 @@ export const useScrobble = () => { 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); - } - - // Jellyfin is the only one that needs to send a 'pause' event to the server - } else if (currentSong?.serverType === ServerType.JELLYFIN) { + } else if (currentSong._serverType === ServerType.JELLYFIN) { + // Send pause event for Jellyfin sendScrobble.mutate({ - apiClientProps: { serverId: currentSong?.serverId || '' }, + apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'pause', id: currentSong.id, @@ -261,33 +333,27 @@ export const useScrobble = () => { submission: false, }, }); - - if (progressIntervalId.current) { - clearInterval(progressIntervalId.current); - progressIntervalId.current = null; - } } else { - const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack(); - const isFisrtTrackInQueue = usePlayerStore.getState().actions.checkIsFirstTrack(); - const previousTimeSec = previous[1]; + // For non-Jellyfin servers, check scrobble conditions on pause + const isLastTrackInQueue = usePlayerStore.getState().isLastTrackInQueue(); + const isFirstTrackInQueue = usePlayerStore.getState().isFirstTrackInQueue(); + const previousTimestamp = previousTimestampRef.current; - // If not already scrobbled, send a 'submission' scrobble if conditions are met const shouldSubmitScrobble = checkScrobbleConditions({ scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, 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 - // Note that if the queue has one item (both first and last), use the elapsed time, as this will otherwise result - // in duplicate scrobbles in tandem with handleScrobbleFromSongChange + // If scrobbling the last song in the queue, use the previous time + // Note: if queue has one item (both first and last), use current time songCompletedDurationMs: - (isLastTrackInQueue && !isFisrtTrackInQueue - ? previousTimeSec - : currentTimeSec) * 1000, + (isLastTrackInQueue && !isFirstTrackInQueue + ? previousTimestamp + : currentTimestamp) * 1000, songDurationMs: currentSong.duration, }); if (!isCurrentSongScrobbled && shouldSubmitScrobble) { sendScrobble.mutate({ - apiClientProps: { serverId: currentSong?.serverId || '' }, + apiClientProps: { serverId: currentSong._serverId || '' }, query: { id: currentSong.id, submission: true, @@ -301,103 +367,58 @@ export const useScrobble = () => { [ isScrobbleEnabled, isPrivateModeEnabled, - sendScrobble, - handleScrobbleFromSeek, scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage, isCurrentSongScrobbled, + sendScrobble, ], ); - // 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) => { + const handleScrobbleFromSeek = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (properties: { timestamp: number }, _prev: { timestamp: number }) => { 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 = - currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined; + const position = properties.timestamp * 1e7; - const shouldSubmitScrobble = checkScrobbleConditions({ - scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, - scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, - songCompletedDurationMs: currentTime, - songDurationMs: currentSong.duration, + sendScrobble.mutate({ + apiClientProps: { serverId: currentSong._serverId || '' }, + query: { + event: 'timeupdate', + 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, - scrobbleSettings?.scrobbleAtDuration, - scrobbleSettings?.scrobbleAtPercentage, - isCurrentSongScrobbled, - sendScrobble, - ], + [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], ); - useEffect(() => { - const unsubSongChange = usePlayerStore.subscribe( - (state): SongEvent => [state.current.song, state.current.time, state.current.player], + // Update previous timestamp on progress for use in status change handler + const handleProgressUpdate = useCallback( + (properties: { timestamp: number }, prev: { timestamp: number }) => { + previousTimestampRef.current = properties.timestamp; + handleScrobbleFromProgress(properties, prev); + }, + [handleScrobbleFromProgress], + ); + + usePlayerEvents( + { + onCurrentSongChange: handleScrobbleFromSongChange, + onPlayerProgress: handleProgressUpdate, + onPlayerSeekToTimestamp: handleScrobbleFromSeek, + onPlayerStatus: handleScrobbleFromStatusChange, + }, + [ handleScrobbleFromSongChange, - { - // We need the current time to check the scrobble condition, but we only want to - // 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], + handleProgressUpdate, + handleScrobbleFromSeek, handleScrobbleFromStatusChange, - { - equalityFn: (a, b) => a[0] === b[0], - }, - ); - - return () => { - unsubSongChange(); - unsubStatusChange(); - }; - }, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]); - - return { handleScrobbleFromSeek, handleScrobbleFromSongRestart }; + ], + ); }; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 431340698..8411758fb 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -41,6 +41,8 @@ interface Actions { items: QueueSong[]; }; increaseVolume: (value: number) => void; + isFirstTrackInQueue: () => boolean; + isLastTrackInQueue: () => boolean; mediaAutoNext: () => PlayerData; mediaNext: () => void; mediaPause: () => void; @@ -606,6 +608,17 @@ export const usePlayerStoreBase = create()( 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: () => { const currentIndex = get().player.index; const player = get().player; @@ -1278,6 +1291,8 @@ export const usePlayerActions = () => { decreaseVolume: state.decreaseVolume, getQueue: state.getQueue, increaseVolume: state.increaseVolume, + isFirstTrackInQueue: state.isFirstTrackInQueue, + isLastTrackInQueue: state.isLastTrackInQueue, mediaAutoNext: state.mediaAutoNext, mediaNext: state.mediaNext, mediaPause: state.mediaPause,