mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 14:22:46 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b62ae22b | |||
| 56a552f893 | |||
| d11c3fa58c | |||
| 54f181c542 | |||
| 1bf51d1d72 | |||
| 37df94bd3b | |||
| dd60499185 | |||
| 009732e745 |
@@ -1062,7 +1062,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to get server info');
|
throw new Error('Failed to get server info');
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFeatures = {};
|
const defaultFeatures = {
|
||||||
|
[ServerFeature.REPORT_PLAYBACK]: [1],
|
||||||
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
...defaultFeatures,
|
...defaultFeatures,
|
||||||
|
|||||||
@@ -289,6 +289,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.removeFavorite,
|
200: ssType._response.removeFavorite,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reportPlayback: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'reportPlayback.view',
|
||||||
|
query: ssType._parameters.reportPlayback,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.reportPlayback,
|
||||||
|
},
|
||||||
|
},
|
||||||
savePlayQueue: {
|
savePlayQueue: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'savePlayQueue.view',
|
path: 'savePlayQueue.view',
|
||||||
|
|||||||
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
features.serverPlayQueue = [1];
|
features.serverPlayQueue = [1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
|
||||||
|
features.reportPlayback = [1];
|
||||||
|
}
|
||||||
|
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||||
},
|
},
|
||||||
getSimilarSongs: async (args) => {
|
getSimilarSongs: async (args) => {
|
||||||
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
scrobble: async (args) => {
|
scrobble: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
||||||
|
if (query.submission) {
|
||||||
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
|
query: {
|
||||||
|
id: query.id,
|
||||||
|
submission: query.submission,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to scrobble');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
||||||
|
|
||||||
|
switch (query.event) {
|
||||||
|
case 'pause':
|
||||||
|
state = 'paused';
|
||||||
|
break;
|
||||||
|
case 'start':
|
||||||
|
state = 'starting';
|
||||||
|
break;
|
||||||
|
case 'timeupdate':
|
||||||
|
case 'unpause':
|
||||||
|
state = 'playing';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
state = 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).reportPlayback({
|
||||||
|
query: {
|
||||||
|
ignoreScrobble: true,
|
||||||
|
mediaId: query.id,
|
||||||
|
mediaType: query.mediaType,
|
||||||
|
playbackRate: query.playbackRate,
|
||||||
|
positionMs: query.position ?? 0,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to report playback');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await ssApiClient(apiClientProps).scrobble({
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
query: {
|
query: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.bar {
|
.bar {
|
||||||
background-color: var(--theme-colors-primary-filled);
|
background-color: var(--theme-colors-primary-filled);
|
||||||
@@ -44,48 +46,45 @@
|
|||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
|
||||||
&::before {
|
|
||||||
right: calc(0.1rem * -1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container {
|
.slider-container {
|
||||||
display: flex;
|
--slider-value-width: 4.75rem;
|
||||||
|
--slider-value-gap: var(--theme-spacing-sm);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
|
||||||
|
gap: var(--slider-value-gap);
|
||||||
|
align-items: center;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-value-wrapper {
|
.slider-value-wrapper {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 50px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width < 768px) {
|
@media (width < 768px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-value-wrapper-elapsed {
|
@media (width < 768px) {
|
||||||
display: flex;
|
.slider-container {
|
||||||
flex: 1;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 4.75rem;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
@media (width < 768px) {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-wrapper {
|
.slider-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 6;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.sliderContainer}>
|
<div className={styles.sliderContainer}>
|
||||||
<div className={styles.sliderValueWrapperElapsed}>
|
<div className={styles.sliderValueWrapper}>
|
||||||
<ScrobbleStatus formattedTime={formattedTime} />
|
<ScrobbleStatus formattedTime={formattedTime} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sliderWrapper}>
|
<div className={styles.sliderWrapper}>
|
||||||
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
|
|||||||
label: styles.label,
|
label: styles.label,
|
||||||
root: styles.root,
|
root: styles.root,
|
||||||
thumb: styles.thumb,
|
thumb: styles.thumb,
|
||||||
track: styles.track,
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
size={6}
|
size={6}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
|
|||||||
|
|
||||||
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||||
|
|
||||||
|
const capForDisplay = (value: number, limit: number) =>
|
||||||
|
limit > 0 ? Math.min(value, limit) : value;
|
||||||
|
|
||||||
|
const progressTowardLimit = (current: number, limit: number) =>
|
||||||
|
limit > 0 ? clampPct((current / limit) * 100) : 0;
|
||||||
|
|
||||||
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||||
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||||
);
|
);
|
||||||
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
|||||||
|
|
||||||
const hookInactive = !scrobbleEnabled || privateMode;
|
const hookInactive = !scrobbleEnabled || privateMode;
|
||||||
|
|
||||||
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
const listenedSecRaw = snapshot.listenedMs / 1000;
|
||||||
const listenPercentOfTrack =
|
const listenedSecDisplay = snapshot.submitted
|
||||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
? snapshot.targetDurationSec
|
||||||
|
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
|
||||||
|
|
||||||
const durationConditionPct =
|
const listenPercentOfTrackRaw =
|
||||||
snapshot.targetDurationSec > 0
|
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||||
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
|
const listenPercentDisplay = snapshot.submitted
|
||||||
: 0;
|
? snapshot.targetPercentage
|
||||||
const percentConditionPct =
|
: capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
|
||||||
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
|
|
||||||
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
|
const durationConditionPct = progressTowardLimit(
|
||||||
: 0;
|
listenedSecDisplay,
|
||||||
|
snapshot.targetDurationSec,
|
||||||
|
);
|
||||||
|
const percentConditionPct = progressTowardLimit(
|
||||||
|
listenPercentDisplay,
|
||||||
|
snapshot.targetPercentage,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard position="top" width={280}>
|
<HoverCard position="top" width={280}>
|
||||||
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
|||||||
<>
|
<>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
{`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrobbleConditionProgress value={durationConditionPct} />
|
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
{`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrobbleConditionProgress value={percentConditionPct} />
|
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ 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 {
|
||||||
|
getServerById,
|
||||||
publishScrobbleDebug,
|
publishScrobbleDebug,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
usePlaybackSettings,
|
usePlaybackSettings,
|
||||||
usePlayerSong,
|
usePlayerSong,
|
||||||
|
usePlayerSpeed,
|
||||||
usePlayerStore,
|
usePlayerStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useTimestampStoreBase,
|
useTimestampStoreBase,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||||
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
type ScrobbleManualHandlers = {
|
type ScrobbleManualHandlers = {
|
||||||
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
|
|||||||
scrobbleManualHandlers?.resetListenedState();
|
scrobbleManualHandlers?.resetListenedState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPositionValue = (seconds: number, useTicks: boolean) => {
|
||||||
|
if (useTicks) {
|
||||||
|
return Math.round(seconds * 1e7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
||||||
- If listened time meets the required percentage of track duration
|
- If listened time meets the required percentage of track duration
|
||||||
@@ -99,6 +111,7 @@ export const useScrobble = () => {
|
|||||||
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
||||||
const sendScrobble = useSendScrobble();
|
const sendScrobble = useSendScrobble();
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
const playbackRate = usePlayerSpeed();
|
||||||
|
|
||||||
const imageUrl = useItemImageUrl({
|
const imageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
@@ -166,6 +179,11 @@ export const useScrobble = () => {
|
|||||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
const currentTime = properties.timestamp;
|
const currentTime = properties.timestamp;
|
||||||
const previousTime = prev.timestamp;
|
const previousTime = prev.timestamp;
|
||||||
@@ -220,19 +238,20 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Jellyfin progress events every 10 seconds
|
// Send progress events every 10 seconds
|
||||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
if (hasPlaybackReport) {
|
||||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||||
if (timeSinceLastProgress >= 10) {
|
if (timeSinceLastProgress >= 10) {
|
||||||
const position = currentTime * 1e7;
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
apiClientProps: { serverId: serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
event: 'timeupdate',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate,
|
||||||
|
position: getPositionValue(currentTime, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,20 +280,15 @@ export const useScrobble = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (shouldSubmitScrobble) {
|
if (shouldSubmitScrobble) {
|
||||||
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
|
|
||||||
// we just send the full duration of the song when it matches the local scrobble conditions
|
|
||||||
const position =
|
|
||||||
currentSong._serverType === ServerType.JELLYFIN
|
|
||||||
? currentSong.duration * 1e7
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentSong.duration ?? 0, useTicks),
|
||||||
submission: true,
|
submission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -295,7 +309,7 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromSongChange = useCallback(
|
const handleScrobbleFromSongChange = useCallback(
|
||||||
@@ -305,6 +319,7 @@ export const useScrobble = () => {
|
|||||||
) => {
|
) => {
|
||||||
const currentSong = properties.song;
|
const currentSong = properties.song;
|
||||||
const previousSong = previousSongRef.current;
|
const previousSong = previousSongRef.current;
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
if (scrobbleSettings?.notify && currentSong?.id) {
|
if (scrobbleSettings?.notify && currentSong?.id) {
|
||||||
@@ -365,6 +380,8 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'start',
|
event: 'start',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
position: 0,
|
position: 0,
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
@@ -388,11 +405,12 @@ export const useScrobble = () => {
|
|||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
flushScrobbleDebug,
|
|
||||||
scrobbleSettings?.notify,
|
scrobbleSettings?.notify,
|
||||||
isScrobbleEnabled,
|
isScrobbleEnabled,
|
||||||
isPrivateModeEnabled,
|
isPrivateModeEnabled,
|
||||||
|
flushScrobbleDebug,
|
||||||
sendScrobble,
|
sendScrobble,
|
||||||
|
playbackRate,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -404,6 +422,11 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!currentSong?.id) {
|
if (!currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -422,7 +445,7 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Position scrobbles are only relevant for Jellyfin
|
// Position scrobbles are only relevant for Jellyfin
|
||||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
if (!hasPlaybackReport) {
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -436,8 +459,6 @@ export const useScrobble = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = properties.timestamp * 1e7;
|
|
||||||
|
|
||||||
lastProgressEventRef.current = properties.timestamp;
|
lastProgressEventRef.current = properties.timestamp;
|
||||||
lastSeekEventRef.current = now;
|
lastSeekEventRef.current = now;
|
||||||
|
|
||||||
@@ -448,7 +469,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
event: 'timeupdate',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(properties.timestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -465,7 +488,7 @@ export const useScrobble = () => {
|
|||||||
);
|
);
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromStatus = useCallback(
|
const handleScrobbleFromStatus = useCallback(
|
||||||
@@ -475,18 +498,22 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!currentSong?.id) {
|
if (!currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only apply to Jellyfin controller scrobble
|
// Only apply to Jellyfin controller scrobble
|
||||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
if (!hasPlaybackReport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const position = currentTimestamp * 1e7;
|
|
||||||
|
|
||||||
// Send pause event when status changes to paused
|
// Send pause event when status changes to paused
|
||||||
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
|
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
|
||||||
@@ -497,7 +524,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'pause',
|
event: 'pause',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentTimestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -523,7 +552,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'unpause',
|
event: 'unpause',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentTimestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -542,7 +573,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromRepeat = useCallback(() => {
|
const handleScrobbleFromRepeat = useCallback(() => {
|
||||||
@@ -552,6 +583,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
|
||||||
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
|
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -570,6 +602,8 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'start',
|
event: 'start',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
position: 0,
|
position: 0,
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
@@ -587,7 +621,7 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
|
||||||
|
|
||||||
// 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(
|
||||||
@@ -607,20 +641,22 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const song = usePlayerStore.getState().getCurrentSong();
|
const song = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const useTicks = song?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!song?.id) {
|
if (!song?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position =
|
|
||||||
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
|
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: song._serverId || '' },
|
apiClientProps: { serverId: song._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(song.duration ?? 0, useTicks),
|
||||||
submission: true,
|
submission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -659,7 +695,7 @@ export const useScrobble = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => registerScrobbleManualHandlers(null);
|
return () => registerScrobbleManualHandlers(null);
|
||||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
|
||||||
|
|
||||||
usePlayerEvents(
|
usePlayerEvents(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
|
|||||||
export enum SubsonicExtensions {
|
export enum SubsonicExtensions {
|
||||||
FORM_POST = 'formPost',
|
FORM_POST = 'formPost',
|
||||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||||
|
PLAYBACK_REPORT = 'playbackReport',
|
||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||||
TRANSCODING = 'transcoding',
|
TRANSCODING = 'transcoding',
|
||||||
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reportPlaybackParameters = z.object({
|
||||||
|
ignoreScrobble: z.boolean().optional(),
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
playbackRate: z.number().optional(),
|
||||||
|
positionMs: z.number(),
|
||||||
|
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportPlayback = z.null();
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
_body: {
|
_body: {
|
||||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
@@ -824,6 +836,7 @@ export const ssType = {
|
|||||||
getTranscodeStream: getTranscodeStreamParameters,
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
|
reportPlayback: reportPlaybackParameters,
|
||||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
saveQueue: saveQueueParameters,
|
saveQueue: saveQueueParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
@@ -877,6 +890,7 @@ export const ssType = {
|
|||||||
playQueueByIndex,
|
playQueueByIndex,
|
||||||
randomSongList,
|
randomSongList,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
|
reportPlayback,
|
||||||
saveQueue,
|
saveQueue,
|
||||||
scrobble,
|
scrobble,
|
||||||
search3,
|
search3,
|
||||||
|
|||||||
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
|
|||||||
albumId?: string;
|
albumId?: string;
|
||||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
||||||
id: string;
|
id: string;
|
||||||
|
mediaType: 'podcast' | 'song';
|
||||||
|
playbackRate: number;
|
||||||
position?: number;
|
position?: number;
|
||||||
submission: boolean;
|
submission: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export enum ServerFeature {
|
|||||||
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
|
REPORT_PLAYBACK = 'reportPlayback',
|
||||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||||
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
||||||
|
|||||||
Reference in New Issue
Block a user