refactor scrobbling to use duration instead of progress (#2010)

- add scrobble status debug and indicator
- add force / reset scrobble
This commit is contained in:
jeffvli
2026-05-12 22:04:46 -07:00
parent 4226da94ec
commit ffe59b2c78
7 changed files with 438 additions and 65 deletions
+2 -1
View File
@@ -696,7 +696,8 @@
"sleepTimer_off": "Off", "sleepTimer_off": "Off",
"sleepTimer_timeRemaining": "{{time}} remaining", "sleepTimer_timeRemaining": "{{time}} remaining",
"sleepTimer_setCustom": "Set timer", "sleepTimer_setCustom": "Set timer",
"sleepTimer_cancel": "Cancel timer" "sleepTimer_cancel": "Cancel timer",
"scrobbleForceSubmit": "Force scrobble"
}, },
"queryBuilder": { "queryBuilder": {
"standardTags": "Standard tags", "standardTags": "Standard tags",
@@ -59,9 +59,24 @@
.slider-value-wrapper { .slider-value-wrapper {
display: flex; display: flex;
flex: 1; flex: 1;
align-self: center; align-items: center;
justify-content: center; justify-content: center;
max-width: 50px; max-width: 50px;
min-height: 0;
@media (width < 768px) {
display: none;
}
}
.slider-value-wrapper-elapsed {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 4.75rem;
min-height: 0;
@media (width < 768px) { @media (width < 768px) {
display: none; display: none;
@@ -4,6 +4,7 @@ import { lazy, Suspense } from 'react';
import { PlayerbarSeekSlider } from './playerbar-seek-slider'; import { PlayerbarSeekSlider } from './playerbar-seek-slider';
import styles from './playerbar-slider.module.css'; import styles from './playerbar-slider.module.css';
import { ScrobbleStatus } from '/@/renderer/features/player/components/scrobble-status';
import { import {
useAppStore, useAppStore,
useAppStoreActions, useAppStoreActions,
@@ -41,17 +42,8 @@ export const PlayerbarSlider = () => {
return ( return (
<> <>
<div className={styles.sliderContainer}> <div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}> <div className={styles.sliderValueWrapperElapsed}>
<Text <ScrobbleStatus formattedTime={formattedTime} />
className={PlaybackSelectors.elapsedTime}
fw={600}
isMuted
isNoSelect
size="xs"
style={{ userSelect: 'none' }}
>
{formattedTime}
</Text>
</div> </div>
<div className={styles.sliderWrapper}> <div className={styles.sliderWrapper}>
{isWaveform ? ( {isWaveform ? (
@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next';
import {
invokeScrobbleForceSubmit,
invokeScrobbleResetListenedState,
} from '/@/renderer/features/player/hooks/use-scrobble';
import { useAppStore, useScrobbleDebugStore, useSettingsStore } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Icon } from '/@/shared/components/icon/icon';
import { Progress } from '/@/shared/components/progress/progress';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
const scrobbleProgressProps = {
'aria-hidden': true,
color: 'var(--theme-colors-primary)',
size: 'xs' as const,
};
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" />
);
export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) => {
const { t } = useTranslation();
const scrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);
const privateMode = useAppStore((state) => state.privateMode);
const snapshot = useScrobbleDebugStore((state) => state.snapshot);
const hookInactive = !scrobbleEnabled || privateMode;
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
const listenPercentOfTrack =
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
const durationConditionPct =
snapshot.targetDurationSec > 0
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
: 0;
const percentConditionPct =
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
: 0;
return (
<HoverCard position="top" width={280}>
<HoverCard.Target>
<Group
align="center"
aria-label={`${t('player.scrobble')}, ${formattedTime}`}
fz="xs"
gap="sm"
justify="center"
onClick={(e) => e.stopPropagation()}
style={{ userSelect: 'none' }}
wrap="nowrap"
>
<Icon
aria-hidden
color={snapshot.submitted ? 'primary' : 'transparent'}
fill={snapshot.submitted ? 'primary' : 'transparent'}
icon="circle"
size="0.375rem"
/>
<Text
className={PlaybackSelectors.elapsedTime}
fw={600}
fz="inherit"
isMuted
isNoSelect
style={{ userSelect: 'none' }}
>
{formattedTime}
</Text>
</Group>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<Stack gap="md" p="sm">
{hookInactive ? (
<Text size="sm">{t('form.privateMode.enabled')}</Text>
) : (
<>
<Stack gap="xs">
<Text size="xs">
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
</Text>
<ScrobbleConditionProgress value={durationConditionPct} />
</Stack>
<Stack gap="xs">
<Text size="xs">
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
</Text>
<ScrobbleConditionProgress value={percentConditionPct} />
</Stack>
<Group gap="xs" grow wrap="nowrap">
<Button
disabled={!snapshot.songId}
onClick={() => invokeScrobbleResetListenedState()}
size="xs"
variant="outline"
>
{t('common.reset')}
</Button>
<Button
disabled={!snapshot.songId || snapshot.submitted}
onClick={() => invokeScrobbleForceSubmit()}
size="xs"
variant="filled"
>
{t('player.scrobbleForceSubmit')}
</Button>
</Group>
</>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
};
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
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 { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { import {
publishScrobbleDebug,
useAppStore, useAppStore,
usePlaybackSettings, usePlaybackSettings,
usePlayerSong, usePlayerSong,
@@ -16,34 +17,64 @@ import { logMsg } from '/@/renderer/utils/logger-message';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = {
forceSubmitScrobble: () => void;
resetListenedState: () => void;
};
let scrobbleManualHandlers: null | ScrobbleManualHandlers = null;
export const registerScrobbleManualHandlers = (next: null | ScrobbleManualHandlers) => {
scrobbleManualHandlers = next;
};
export const invokeScrobbleForceSubmit = () => {
scrobbleManualHandlers?.forceSubmitScrobble();
};
export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState();
};
/* /*
Scrobble Conditions (match any): Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If the song has been played for the required percentage - If listened time meets the required percentage of track duration
- If the song has been played for the required duration - If listened time meets the required duration (seconds)
Scrobble Events: Listen time advances only while PLAYING, from consecutive timestamp deltas.
- On song timestamp update: Seeks and other timeline jumps re-baseline the next sample without counting
- If the song has been played for the required percentage the jump as listen time; accumulated listen time is kept across seeks.
- If the song has been played for the required duration
- When the song changes (or is completed): Listen time and submission state reset when the playhead returns to the start
- Current song: Sends the 'playing' scrobble event of the track (position before SCROBBLE_TRACK_BEGIN_SEC), e.g. seek-to-start or
- Resets the 'isCurrentSongScrobbled' state to false restart-from-near-zero. Song change and repeat still reset for a new play-through.
- When the song is restarted: Jellyfin progress APIs still use playback position (ticks), not listen time:
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false - Periodic timeupdate while playing
- Resets the 'isCurrentSongScrobbled' state to false - timeupdate on seek
- pause / unpause
- When the song is seeked: Other events:
- Sends the 'timeupdate' scrobble event (Jellyfin only) - When the song changes: sends 'start' when the new track is playing;
clears submission flag and listen accumulator for the new track.
- When the song is restarted (near 0 after 10s+): clears submission flag
and listen accumulator.
Progress Events: - When the song is seeked: Jellyfin sends timeupdate (throttled). Seeking from
- When the song is playing (Jellyfin only): at/after the intro into the start of the track clears listen accumulator and
- Sends the 'progress' scrobble event on an interval submission flag; other seeks keep accumulated listen time.
*/ */
// Positions before this time (seconds) count as the start of the track for listen/scrobble resets.
const SCROBBLE_TRACK_BEGIN_SEC = 5;
// Min previous position (seconds) to treat a jump to the start as a full restart.
const SCROBBLE_RESTART_PREVIOUS_MIN_SEC = 10;
// Max seconds between timestamp samples to count as continuous play (above poll interval, below a teleport).
const MAX_LISTEN_DELTA_SEC = 5;
const checkScrobbleConditions = (args: { const checkScrobbleConditions = (args: {
scrobbleAtDurationMs: number; scrobbleAtDurationMs: number;
scrobbleAtPercentage: number; scrobbleAtPercentage: number;
@@ -56,10 +87,10 @@ const checkScrobbleConditions = (args: {
? (songCompletedDurationMs / songDurationMs) * 100 ? (songCompletedDurationMs / songDurationMs) * 100
: 0; : 0;
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage; const shouldScrobbleBasedOnPercentage = percentageOfSongCompleted >= scrobbleAtPercentage;
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs; const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration; return shouldScrobbleBasedOnPercentage || shouldScrobbleBasedOnDuration;
}; };
export const useScrobble = () => { export const useScrobble = () => {
@@ -77,7 +108,12 @@ export const useScrobble = () => {
}); });
const imageUrlRef = useRef<null | string | undefined>(imageUrl); const imageUrlRef = useRef<null | string | undefined>(imageUrl);
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false); const isCurrentSongScrobbledRef = useRef(false);
const listenedMsRef = useRef(0);
const lastListenSampleTimeRef = useRef<null | number>(null);
const scrobbleAtDurationMsRef = useRef(0);
const scrobbleAtPercentageRef = useRef(75);
const previousSongRef = useRef<QueueSong | undefined>(undefined); const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0); const previousTimestampRef = useRef<number>(0);
const lastProgressEventRef = useRef<number>(0); const lastProgressEventRef = useRef<number>(0);
@@ -89,30 +125,101 @@ export const useScrobble = () => {
imageUrlRef.current = imageUrl; imageUrlRef.current = imageUrl;
}, [imageUrl]); }, [imageUrl]);
useEffect(() => {
scrobbleAtDurationMsRef.current = (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000;
scrobbleAtPercentageRef.current = scrobbleSettings?.scrobbleAtPercentage ?? 75;
}, [scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage]);
const flushScrobbleDebug = useCallback(() => {
const song = usePlayerStore.getState().getCurrentSong();
const status = usePlayerStore.getState().player.status;
const positionSec = useTimestampStoreBase.getState().timestamp;
const trackDurationMs = song?.duration ?? 0;
const eligibilityMet = Boolean(
song?.id &&
checkScrobbleConditions({
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
songCompletedDurationMs: listenedMsRef.current,
songDurationMs: trackDurationMs,
}),
);
publishScrobbleDebug({
eligibilityMet,
lastListenSampleTimeSec: lastListenSampleTimeRef.current,
listenedMs: listenedMsRef.current,
playerStatus: status,
positionSec,
songId: song?.id,
songName: song?.name,
submitted: isCurrentSongScrobbledRef.current,
targetDurationSec: scrobbleAtDurationMsRef.current / 1000,
targetPercentage: scrobbleAtPercentageRef.current,
trackDurationMs,
});
}, []);
const handleScrobbleFromProgress = useCallback( const handleScrobbleFromProgress = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => { (properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
const currentTime = properties.timestamp; const currentTime = properties.timestamp;
const previousTime = prev.timestamp; const previousTime = prev.timestamp;
// Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds if (!currentSong?.id) {
return;
}
if (currentStatus !== PlayerStatus.PLAYING) {
lastListenSampleTimeRef.current = currentTime;
return;
}
// Detect song restart: when timestamp resets to near 0 and was playing past the intro
if ( if (
currentTime < previousTime && currentTime < previousTime &&
currentTime < 5 && // Reset to near 0 currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
previousTime >= 10 // Was playing for at least 10 seconds previousTime >= SCROBBLE_RESTART_PREVIOUS_MIN_SEC
) { ) {
setIsCurrentSongScrobbled(false); isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0; lastProgressEventRef.current = 0;
previousTimestampRef.current = 0; previousTimestampRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
return; return;
} }
const lastSample = lastListenSampleTimeRef.current;
if (lastSample === null) {
const prevSec = prev.timestamp;
if (currentTime > prevSec && currentTime - prevSec <= MAX_LISTEN_DELTA_SEC) {
listenedMsRef.current += (currentTime - prevSec) * 1000;
}
lastListenSampleTimeRef.current = currentTime;
} else {
const deltaSec = currentTime - lastSample;
const jumpedBackToTrackStart =
currentTime < lastSample &&
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
lastSample >= SCROBBLE_TRACK_BEGIN_SEC;
if (jumpedBackToTrackStart) {
listenedMsRef.current = 0;
isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0;
lastListenSampleTimeRef.current = currentTime;
} else if (currentTime < lastSample || deltaSec > MAX_LISTEN_DELTA_SEC) {
lastListenSampleTimeRef.current = currentTime;
} else if (deltaSec > 0) {
listenedMsRef.current += deltaSec * 1000;
lastListenSampleTimeRef.current = currentTime;
}
}
// Send Jellyfin progress events every 10 seconds // Send Jellyfin progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) { if (currentSong._serverType === ServerType.JELLYFIN) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current; const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
@@ -144,12 +251,12 @@ export const useScrobble = () => {
} }
} }
// Check if we should submit scrobble based on conditions // Check if we should submit scrobble based on listened time
if (!isCurrentSongScrobbled) { if (!isCurrentSongScrobbledRef.current) {
const shouldSubmitScrobble = checkScrobbleConditions({ const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, scrobbleAtPercentage: scrobbleAtPercentageRef.current,
songCompletedDurationMs: currentTime * 1000, songCompletedDurationMs: listenedMsRef.current,
songDurationMs: currentSong.duration, songDurationMs: currentSong.duration,
}); });
@@ -177,25 +284,18 @@ export const useScrobble = () => {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
reason: 'from song progress', reason: 'from listened time',
}, },
}); });
}, },
}, },
); );
setIsCurrentSongScrobbled(true); isCurrentSongScrobbledRef.current = true;
} }
} }
}, },
[ [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
); );
const handleScrobbleFromSongChange = useCallback( const handleScrobbleFromSongChange = useCallback(
@@ -240,11 +340,16 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) { if (!isScrobbleEnabled || isPrivateModeEnabled) {
previousSongRef.current = currentSong; previousSongRef.current = currentSong;
previousTimestampRef.current = 0; previousTimestampRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
flushScrobbleDebug();
return; return;
} }
setIsCurrentSongScrobbled(false); isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0; lastProgressEventRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
// Use a timeout to prevent spamming the server when switching songs quickly // Use a timeout to prevent spamming the server when switching songs quickly
clearTimeout(songChangeTimeoutRef.current); clearTimeout(songChangeTimeoutRef.current);
@@ -280,8 +385,15 @@ export const useScrobble = () => {
previousSongRef.current = currentSong; previousSongRef.current = currentSong;
previousTimestampRef.current = 0; previousTimestampRef.current = 0;
flushScrobbleDebug();
}, },
[scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [
flushScrobbleDebug,
scrobbleSettings?.notify,
isScrobbleEnabled,
isPrivateModeEnabled,
sendScrobble,
],
); );
const handleScrobbleFromSeek = useCallback( const handleScrobbleFromSeek = useCallback(
@@ -297,8 +409,21 @@ export const useScrobble = () => {
return; return;
} }
const sampleBeforeSeek = lastListenSampleTimeRef.current;
lastListenSampleTimeRef.current = properties.timestamp;
if (
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
(sampleBeforeSeek === null || sampleBeforeSeek >= SCROBBLE_TRACK_BEGIN_SEC)
) {
listenedMsRef.current = 0;
isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0;
}
// Position scrobbles are only relevant for Jellyfin // Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) { if (currentSong._serverType !== ServerType.JELLYFIN) {
flushScrobbleDebug();
return; return;
} }
@@ -307,6 +432,7 @@ export const useScrobble = () => {
// Only allow seek scrobble once per second // Only allow seek scrobble once per second
if (timeSinceLastSeek < 1000) { if (timeSinceLastSeek < 1000) {
flushScrobbleDebug();
return; return;
} }
@@ -337,8 +463,9 @@ export const useScrobble = () => {
}, },
}, },
); );
flushScrobbleDebug();
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
); );
const handleScrobbleFromStatus = useCallback( const handleScrobbleFromStatus = useCallback(
@@ -412,8 +539,10 @@ export const useScrobble = () => {
}, },
); );
} }
flushScrobbleDebug();
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
); );
const handleScrobbleFromRepeat = useCallback(() => { const handleScrobbleFromRepeat = useCallback(() => {
@@ -428,9 +557,11 @@ export const useScrobble = () => {
return; return;
} }
setIsCurrentSongScrobbled(false); isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0; lastProgressEventRef.current = 0;
previousTimestampRef.current = 0; previousTimestampRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
sendScrobble.mutate( sendScrobble.mutate(
{ {
@@ -455,17 +586,81 @@ export const useScrobble = () => {
}, },
}, },
); );
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]); flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
// Update previous timestamp on progress for use in status change handler // Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback( const handleProgressUpdate = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => { (properties: { timestamp: number }, prev: { timestamp: number }) => {
previousTimestampRef.current = properties.timestamp; previousTimestampRef.current = properties.timestamp;
handleScrobbleFromProgress(properties, prev); handleScrobbleFromProgress(properties, prev);
flushScrobbleDebug();
}, },
[handleScrobbleFromProgress], [flushScrobbleDebug, handleScrobbleFromProgress],
); );
useEffect(() => {
registerScrobbleManualHandlers({
forceSubmitScrobble: () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) {
return;
}
const song = usePlayerStore.getState().getCurrentSong();
if (!song?.id) {
return;
}
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: song._serverId || '' },
query: {
albumId: song.albumId,
id: song.id,
position,
submission: true,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, {
category: LogCategory.SCROBBLE,
meta: {
id: song.id,
reason: 'forced from UI',
},
});
},
},
);
isCurrentSongScrobbledRef.current = true;
flushScrobbleDebug();
},
resetListenedState: () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) {
return;
}
const song = usePlayerStore.getState().getCurrentSong();
if (!song?.id) {
return;
}
listenedMsRef.current = 0;
isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0;
lastListenSampleTimeRef.current = useTimestampStoreBase.getState().timestamp;
flushScrobbleDebug();
},
});
return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
usePlayerEvents( usePlayerEvents(
{ {
onCurrentSongChange: handleScrobbleFromSongChange, onCurrentSongChange: handleScrobbleFromSongChange,
+1
View File
@@ -2,6 +2,7 @@ export * from './app.store';
export * from './auth.store'; export * from './auth.store';
export * from './full-screen-player.store'; export * from './full-screen-player.store';
export * from './player.store'; export * from './player.store';
export * from './scrobble-debug.store';
export * from './scroll.store'; export * from './scroll.store';
export * from './settings.store'; export * from './settings.store';
export * from './timestamp.store'; export * from './timestamp.store';
@@ -0,0 +1,45 @@
import { createWithEqualityFn } from 'zustand/traditional';
import { PlayerStatus } from '/@/shared/types/types';
export type ScrobbleDebugSnapshot = {
eligibilityMet: boolean;
lastListenSampleTimeSec: null | number;
listenedMs: number;
playerStatus: PlayerStatus;
positionSec: number;
songId?: string;
songName?: string;
submitted: boolean;
targetDurationSec: number;
targetPercentage: number;
trackDurationMs: number;
};
const initialSnapshot: ScrobbleDebugSnapshot = {
eligibilityMet: false,
lastListenSampleTimeSec: null,
listenedMs: 0,
playerStatus: PlayerStatus.PAUSED,
positionSec: 0,
songId: undefined,
songName: undefined,
submitted: false,
targetDurationSec: 240,
targetPercentage: 75,
trackDurationMs: 0,
};
type ScrobbleDebugStore = {
snapshot: ScrobbleDebugSnapshot;
};
export const useScrobbleDebugStore = createWithEqualityFn<ScrobbleDebugStore>()(() => ({
snapshot: initialSnapshot,
}));
export const publishScrobbleDebug = (partial: Partial<ScrobbleDebugSnapshot>) => {
useScrobbleDebugStore.setState((state) => ({
snapshot: { ...state.snapshot, ...partial },
}));
};