Add scrobble functionality (#19)

* Fix slider bar background to use theme

* Add "scrobbleAtDuration" to settings store

* Add subscribeWithSelector and playCount incrementor

* Add scrobbling API and mutation

* Add scrobble settings

* Begin support for multi-server queue handling

* Dynamically set version on auth header

* Add scrobbling functionality for navidrome/jellyfin
This commit is contained in:
Jeff 2023-01-30 20:01:57 -08:00 committed by GitHub
parent 85bf910d65
commit 484c96187c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1253 additions and 653 deletions

View file

@ -43,9 +43,12 @@ import type {
RawAddToPlaylistResponse,
RemoveFromPlaylistArgs,
RawRemoveFromPlaylistResponse,
ScrobbleArgs,
RawScrobbleResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
import { ServerListItem } from '/@/renderer/types';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
@ -75,6 +78,7 @@ export type ControllerEndpoint = Partial<{
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
@ -114,6 +118,7 @@ const endpoints: ApiController = {
getTopSongs: undefined,
getUserList: undefined,
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
scrobble: jellyfinApi.scrobble,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
@ -145,6 +150,7 @@ const endpoints: ApiController = {
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
scrobble: subsonicApi.scrobble,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@ -173,13 +179,14 @@ const endpoints: ApiController = {
getSongList: undefined,
getTopSongs: subsonicApi.getTopSongList,
getUserList: undefined,
scrobble: subsonicApi.scrobble,
updatePlaylist: undefined,
updateRating: undefined,
},
};
const apiController = (endpoint: keyof ControllerEndpoint) => {
const serverType = useAuthStore.getState().currentServer?.type;
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
@ -287,6 +294,10 @@ const getTopSongList = async (args: TopSongListArgs) => {
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
};
export const controller = {
addToPlaylist,
createFavorite,
@ -307,6 +318,7 @@ export const controller = {
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
updatePlaylist,
updateRating,
};

View file

@ -70,10 +70,13 @@ import {
LibraryItem,
RemoveFromPlaylistArgs,
AddToPlaylistArgs,
ScrobbleArgs,
RawScrobbleResponse,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import packageJson from '../../../package.json';
const getCommaDelimitedString = (value: string[]) => {
return value.join(',');
@ -93,8 +96,7 @@ const authenticate = async (
const data = await ky
.post(`${cleanServerUrl}/users/authenticatebyname`, {
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
},
json: {
pw: body.password,
@ -581,6 +583,81 @@ const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> =>
};
};
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
const { query, server } = args;
const position = query.position && Math.round(query.position);
if (query.submission) {
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
api.post(`sessions/playing/stopped`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'start') {
await api.post(`sessions/playing`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'pause') {
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
EventName: query.event,
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'unpause') {
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
EventName: query.event,
IsPaused: false,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
};
const getStreamUrl = (args: {
container?: string;
deviceId: string;
@ -927,6 +1004,7 @@ export const jellyfinApi = {
getPlaylistSongList,
getSongList,
removeFromPlaylist,
scrobble,
updatePlaylist,
};

View file

@ -26,6 +26,7 @@ import type {
SSArtistInfo,
SSSong,
SSTopSongList,
SSScrobbleParams,
} from '/@/renderer/api/subsonic.types';
import {
AlbumArtistDetailArgs,
@ -42,6 +43,8 @@ import {
QueueSong,
RatingArgs,
RatingResponse,
RawScrobbleResponse,
ScrobbleArgs,
ServerListItem,
ServerType,
TopSongListArgs,
@ -386,6 +389,25 @@ const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
return data.artistInfo2;
};
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSScrobbleParams = {
id: query.id,
submission: query.submission,
...defaultParams,
};
await api.get('rest/scrobble.view', {
prefixUrl: server?.url,
searchParams,
signal,
});
return null;
};
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
const imageUrl =
getCoverArtUrl({
@ -465,6 +487,7 @@ export const subsonicApi = {
getGenreList,
getMusicFolderList,
getTopSongList,
scrobble,
updateRating,
};

View file

@ -216,3 +216,9 @@ export type SSTopSongList = {
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};

View file

@ -295,6 +295,7 @@ export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = {
_serverId?: string;
server: ServerListItem | null;
signal?: AbortSignal;
};
@ -1014,3 +1015,17 @@ export type ArtistInfoQuery = {
};
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
// Scrobble
export type RawScrobbleResponse = null | undefined;
export type ScrobbleArgs = {
query: ScrobbleQuery;
} & BaseEndpointArgs;
export type ScrobbleQuery = {
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
id: string;
position?: number;
submission: boolean;
};

View file

@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
background-color: var(--slider-track-bg);
}
& .mantine-Slider-bar {
background-color: var(--primary-color);
}
& .mantine-Slider-thumb {
width: 1rem;
height: 1rem;

View file

@ -181,6 +181,7 @@ export const LeftControls = () => {
<MetadataStack layout="position">
<LineItem>
<Group
noWrap
align="flex-start"
spacing="xs"
>

View file

@ -12,6 +12,7 @@ import {
useShuffleStatus,
} from '/@/renderer/store';
import { usePlayerType, useSettingsStore } from '/@/renderer/store/settings.store';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
@ -35,6 +36,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
const resetPlayers = useCallback(() => {
if (player1Ref.getInternalPlayer()) {
player1Ref.getInternalPlayer().currentTime = 0;
@ -289,6 +292,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
// Reset the current track more than 10 seconds have elapsed
if (currentTime >= 10) {
setCurrentTime(0);
handleScrobbleFromSongRestart(currentTime);
if (isMpvPlayer) {
return mpvPlayer.seekTo(0);
@ -373,6 +377,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
}, [
checkIsFirstTrack,
currentPlayerRef,
handleScrobbleFromSongRestart,
isMpvPlayer,
pause,
playerType,
@ -438,13 +443,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
(e: number | any) => {
setCurrentTime(e);
handleScrobbleFromSeek(e);
if (isMpvPlayer) {
mpvPlayer.seekTo(e);
} else {
currentPlayerRef.seekTo(e);
}
},
[currentPlayerRef, isMpvPlayer, setCurrentTime],
[currentPlayerRef, handleScrobbleFromSeek, isMpvPlayer, setCurrentTime],
);
const handleQuit = useCallback(() => {

View file

@ -1,23 +1,319 @@
import { useState } from 'react';
import { usePlayerStore } from '/@/renderer/store';
import { useEffect, useCallback, useState, useRef } from 'react';
import { QueueSong, ServerType } from '/@/renderer/api/types';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { usePlayerSettings } from '/@/renderer/store/settings.store';
import { PlayerStatus } from '/@/renderer/types';
/*
Scrobble Conditions (match any):
- If the song has been played for the required percentage
- If the song has been played for the required duration
Scrobble Events:
- 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
- Resets the 'isCurrentSongScrobbled' state to false
- When the song is paused:
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
- Sends the 'pause' scrobble event (Jellyfin only)
- When the song is restarted:
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
- Resets the 'isCurrentSongScrobbled' state to false
- When the song is seeked:
- Sends the 'timeupdate' scrobble event (Jellyfin only)
Progress Events (Jellyfin only):
- When the song is playing:
- Sends the 'progress' scrobble event on an interval
*/
const checkScrobbleConditions = (args: {
scrobbleAtDuration: number;
scrobbleAtPercentage: number;
songCompletedDuration: number;
songDuration: number;
}) => {
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
const percentageOfSongCompleted = songDuration ? (songCompletedDuration / songDuration) * 100 : 0;
return (
percentageOfSongCompleted >= scrobbleAtPercentage || songCompletedDuration >= scrobbleAtDuration
);
};
export const useScrobble = () => {
const [isScrobbled, setIsScrobbled] = useState(false);
const status = useCurrentStatus();
const scrobbleSettings = usePlayerSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const sendScrobble = useSendScrobble();
const currentSongDuration = usePlayerStore((state) => state.current.song?.duration);
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const scrobbleAtPercentage = usePlayerStore((state) => state.settings.scrobbleAtPercentage);
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
console.log('currentSongDuration', currentSongDuration);
const currentSong = usePlayerStore.getState().current.song;
const scrobbleAtTime = (currentSongDuration * scrobbleAtPercentage) / 100;
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
console.log('scrobbleAtTime', scrobbleAtTime);
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
console.log('render');
const handleScrobble = () => {
console.log('scrobble complete');
};
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
});
},
[isScrobbleEnabled, sendScrobble],
);
return { handleScrobble, isScrobbled, setIsScrobbled };
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleScrobbleFromSongChange = useCallback(
(current: (QueueSong | number | undefined)[], previous: (QueueSong | number | undefined)[]) => {
if (!isScrobbleEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0] as QueueSong;
const previousSongTime = previous[1] as number;
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: previousSongTime,
songDuration: previousSong.duration,
});
if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?.serverType === ServerType.JELLYFIN
) {
const position =
previousSong?.serverType === ServerType.JELLYFIN ? previousSongTime * 1e7 : undefined;
sendScrobble.mutate({
_serverId: previousSong?.serverId,
query: {
id: previousSong.id,
position,
submission: true,
},
});
}
}
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0] as QueueSong | undefined;
// Send start scrobble when song changes and the new song is playing
if (status === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
}
}, 2000);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
status,
handleScrobbleFromSeek,
],
);
const handleScrobbleFromStatusChange = useCallback(
(status: PlayerStatus | undefined) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7
: undefined;
// Whenever the player is restarted, send a 'start' scrobble
if (status === PlayerStatus.PLAYING) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
event: 'unpause',
id: currentSong.id,
position,
submission: false,
},
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
event: 'pause',
id: currentSong.id,
position,
submission: false,
},
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
}
} else {
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: usePlayerStore.getState().current.time,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
id: currentSong.id,
submission: true,
},
});
setIsCurrentSongScrobbled(true);
}
}
},
[
isScrobbleEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
],
);
// 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) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: currentTime,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
id: currentSong.id,
position,
submission: true,
},
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
_serverId: currentSong?.serverId,
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
}
setIsCurrentSongScrobbled(false);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.current.time],
handleScrobbleFromSongChange,
{
// We need the current time to check the scrobble condition, but we only want to
// trigger the callback when the song changes
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => state.current.status,
handleScrobbleFromStatusChange,
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
};

View file

@ -0,0 +1,25 @@
import { useMutation } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { RawScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
import { MutationOptions } from '/@/renderer/lib/react-query';
import { useAuthStore, useCurrentServer, useIncrementQueuePlayCount } from '/@/renderer/store';
export const useSendScrobble = (options?: MutationOptions) => {
const currentServer = useCurrentServer();
const incrementPlayCount = useIncrementQueuePlayCount();
return useMutation<RawScrobbleResponse, HTTPError, Omit<ScrobbleArgs, 'server'>, null>({
mutationFn: (args) => {
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
return api.controller.scrobble({ ...args, server });
},
onSuccess: (_data, variables) => {
// Manually increment the play count for the song in the queue if scrobble was submitted
if (variables.query.submission) {
incrementPlayCount([variables.query.id]);
}
},
...options,
});
};

View file

@ -263,6 +263,84 @@ export const PlaybackTab = () => {
},
];
const scrobbleOptions = [
{
control: (
<Switch
aria-label="Toggle scrobble"
defaultChecked={settings.scrobble.enabled}
onChange={(e) => {
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
enabled: e.currentTarget.checked,
},
},
});
}}
/>
),
description: 'Enable or disable scrobbling to your media server',
isHidden: !isElectron(),
title: 'Scrobble',
},
{
control: (
<Slider
aria-label="Scrobble percentage"
defaultValue={settings.scrobble.scrobbleAtPercentage}
label={`${settings.scrobble.scrobbleAtPercentage}%`}
max={90}
min={25}
w={100}
onChange={(e) => {
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtPercentage: e,
},
},
});
}}
/>
),
description: 'The percentage of the song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble percentage*',
},
{
control: (
<NumberInput
aria-label="Scrobble duration in seconds"
defaultValue={settings.scrobble.scrobbleAtDuration}
max={1200}
min={0}
width={75}
onChange={(e) => {
if (e === '') return;
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtDuration: e,
},
},
});
}}
/>
),
description:
'The duration in seconds of a song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble duration (seconds)*',
},
];
const otherOptions = [
{
control: (
@ -370,6 +448,22 @@ export const PlaybackTab = () => {
/>
))}
<Divider />
{scrobbleOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`'scrobble-${option.title}`}
{...option}
/>
))}
<Text
$secondary
size="xs"
>
*The scrobble will be submitted if one or more of the above conditions is met
</Text>
<Divider />
{otherOptions
.filter((o) => !o.isHidden)
.map((option) => (

View file

@ -20,6 +20,7 @@ export interface AuthSlice extends AuthState {
actions: {
addServer: (args: ServerListItem) => void;
deleteServer: (id: string) => void;
getServer: (id?: string) => ServerListItem | undefined;
setCurrentServer: (server: ServerListItem | null) => void;
updateServer: (id: string, args: Partial<ServerListItem>) => void;
};
@ -28,7 +29,7 @@ export interface AuthSlice extends AuthState {
export const useAuthStore = create<AuthSlice>()(
persist(
devtools(
immer((set) => ({
immer((set, get) => ({
actions: {
addServer: (args) => {
set((state) => {
@ -43,6 +44,9 @@ export const useAuthStore = create<AuthSlice>()(
}
});
},
getServer: (id) => {
return get().serverList.find((server) => server.id === id);
},
setCurrentServer: (server) => {
set((state) => {
state.currentServer = server;

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,7 @@ export interface SettingsState {
playButtonBehavior: Play;
scrobble: {
enabled: boolean;
scrobbleAtDuration: number;
scrobbleAtPercentage: number;
};
skipButtons: {
@ -98,7 +99,8 @@ export const useSettingsStore = create<SettingsSlice>()(
muted: false,
playButtonBehavior: Play.NOW,
scrobble: {
enabled: false,
enabled: true,
scrobbleAtDuration: 240,
scrobbleAtPercentage: 75,
},
skipButtons: {
@ -220,7 +222,7 @@ export const useSettingsStore = create<SettingsSlice>()(
return merge(currentState, persistedState);
},
name: 'store_settings',
version: 1,
version: 2,
},
),
);