mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-12 07:12:58 +02:00
refactor scrobbling to use duration instead of progress (#2010)
- add scrobble status debug and indicator - add force / reset scrobble
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user