diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index f3fd235a..ff823a7d 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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; @@ -75,6 +78,7 @@ export type ControllerEndpoint = Partial<{ getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; + scrobble: (args: ScrobbleArgs) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; updateRating: (args: RatingArgs) => Promise; }>; @@ -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, }; diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index d860de3e..37a30730 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -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 => }; }; +const scrobble = async (args: ScrobbleArgs): Promise => { + 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, }; diff --git a/src/renderer/api/subsonic.api.ts b/src/renderer/api/subsonic.api.ts index ff2aca77..eb9b3ac6 100644 --- a/src/renderer/api/subsonic.api.ts +++ b/src/renderer/api/subsonic.api.ts @@ -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 => { return data.artistInfo2; }; +const scrobble = async (args: ScrobbleArgs): Promise => { + 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, }; diff --git a/src/renderer/api/subsonic.types.ts b/src/renderer/api/subsonic.types.ts index 322ade49..7f6c0bd3 100644 --- a/src/renderer/api/subsonic.types.ts +++ b/src/renderer/api/subsonic.types.ts @@ -216,3 +216,9 @@ export type SSTopSongList = { startIndex: number; totalRecordCount: number | null; }; + +export type SSScrobbleParams = { + id: string; + submission?: boolean; + time?: number; +}; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index f7a795d1..d1169a55 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -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; +}; diff --git a/src/renderer/components/slider/index.tsx b/src/renderer/components/slider/index.tsx index 12133e2e..3a4214c3 100644 --- a/src/renderer/components/slider/index.tsx +++ b/src/renderer/components/slider/index.tsx @@ -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; diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index f7ec41cd..6155ede0 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -181,6 +181,7 @@ export const LeftControls = () => { diff --git a/src/renderer/features/player/hooks/use-center-controls.ts b/src/renderer/features/player/hooks/use-center-controls.ts index 4a1e79d1..d8926bfb 100644 --- a/src/renderer/features/player/hooks/use-center-controls.ts +++ b/src/renderer/features/player/hooks/use-center-controls.ts @@ -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(() => { diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index 671c5a5b..f839f80d 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -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 | null>(null); + const songChangeTimeoutId = useRef | 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); + 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); + } + } 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 }; }; diff --git a/src/renderer/features/player/mutations/scrobble-mutation.ts b/src/renderer/features/player/mutations/scrobble-mutation.ts new file mode 100644 index 00000000..d38b5f41 --- /dev/null +++ b/src/renderer/features/player/mutations/scrobble-mutation.ts @@ -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, 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, + }); +}; diff --git a/src/renderer/features/settings/components/playback-tab.tsx b/src/renderer/features/settings/components/playback-tab.tsx index 48970d93..ddd37a1f 100644 --- a/src/renderer/features/settings/components/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback-tab.tsx @@ -263,6 +263,84 @@ export const PlaybackTab = () => { }, ]; + const scrobbleOptions = [ + { + control: ( + { + setSettings({ + player: { + ...settings, + scrobble: { + ...settings.scrobble, + enabled: e.currentTarget.checked, + }, + }, + }); + }} + /> + ), + description: 'Enable or disable scrobbling to your media server', + isHidden: !isElectron(), + title: 'Scrobble', + }, + { + control: ( + { + 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: ( + { + 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 = () => { /> ))} + + {scrobbleOptions + .filter((o) => !o.isHidden) + .map((option) => ( + + ))} + + *The scrobble will be submitted if one or more of the above conditions is met + + {otherOptions .filter((o) => !o.isHidden) .map((option) => ( diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 1c99b85a..d63d76ab 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -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) => void; }; @@ -28,7 +29,7 @@ export interface AuthSlice extends AuthState { export const useAuthStore = create()( persist( devtools( - immer((set) => ({ + immer((set, get) => ({ actions: { addServer: (args) => { set((state) => { @@ -43,6 +44,9 @@ export const useAuthStore = create()( } }); }, + getServer: (id) => { + return get().serverList.find((server) => server.id === id); + }, setCurrentServer: (server) => { set((state) => { state.currentServer = server; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index b8b80d10..64835efa 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -3,7 +3,7 @@ import merge from 'lodash/merge'; import shuffle from 'lodash/shuffle'; import { nanoid } from 'nanoid/non-secure'; import create from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; +import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import shallow from 'zustand/shallow'; import { QueueSong, Song } from '/@/renderer/api/types'; @@ -62,6 +62,7 @@ export interface PlayerSlice extends PlayerState { clearQueue: () => PlayerData; getPlayerData: () => PlayerData; getQueueData: () => QueueData; + incrementPlayCount: (ids: string[]) => string[]; moveToBottomOfQueue: (uniqueIds: string[]) => PlayerData; moveToTopOfQueue: (uniqueIds: string[]) => PlayerData; next: () => PlayerData; @@ -88,223 +89,283 @@ export interface PlayerSlice extends PlayerState { } export const usePlayerStore = create()( - persist( - devtools( - immer((set, get) => ({ - actions: { - addToQueue: (songs, type) => { - const { shuffledIndex } = get().current; - const shuffledQueue = get().queue.shuffled; - const queueSongs = map(songs, (song) => ({ - ...song, - uniqueId: nanoid(), - })); + subscribeWithSelector( + persist( + devtools( + immer((set, get) => ({ + actions: { + addToQueue: (songs, type) => { + const { shuffledIndex } = get().current; + const shuffledQueue = get().queue.shuffled; + const queueSongs = map(songs, (song) => ({ + ...song, + uniqueId: nanoid(), + })); - if (type === Play.NOW) { - if (get().shuffle === PlayerShuffle.TRACK) { - const shuffledSongs = shuffle(queueSongs); - const foundIndex = queueSongs.findIndex( - (song) => song.uniqueId === shuffledSongs[0].uniqueId, - ); - set((state) => { - state.queue.shuffled = shuffledSongs.map((song) => song.uniqueId); - }); + if (type === Play.NOW) { + if (get().shuffle === PlayerShuffle.TRACK) { + const shuffledSongs = shuffle(queueSongs); + const foundIndex = queueSongs.findIndex( + (song) => song.uniqueId === shuffledSongs[0].uniqueId, + ); + set((state) => { + state.queue.shuffled = shuffledSongs.map((song) => song.uniqueId); + }); + + set((state) => { + state.queue.default = queueSongs; + state.current.time = 0; + state.current.player = 1; + state.current.index = foundIndex; + state.current.shuffledIndex = 0; + state.current.song = shuffledSongs[0]; + }); + } else { + set((state) => { + state.queue.default = queueSongs; + state.current.time = 0; + state.current.player = 1; + state.current.index = 0; + state.current.shuffledIndex = 0; + state.current.song = queueSongs[0]; + }); + } + } else if (type === Play.LAST) { + // Shuffle the queue after the current track + const shuffledQueueWithNewSongs = + get().shuffle === PlayerShuffle.TRACK + ? [ + ...shuffledQueue.slice(0, shuffledIndex + 1), + ...shuffle([ + ...queueSongs.map((song) => song.uniqueId), + ...shuffledQueue.slice(shuffledIndex + 1), + ]), + ] + : []; set((state) => { - state.queue.default = queueSongs; - state.current.time = 0; - state.current.player = 1; - state.current.index = foundIndex; - state.current.shuffledIndex = 0; - state.current.song = shuffledSongs[0]; + state.queue.default = [...get().queue.default, ...queueSongs]; + state.queue.shuffled = shuffledQueueWithNewSongs; }); - } else { + } else if (type === Play.NEXT) { + const queue = get().queue.default; + const currentIndex = get().current.index; + + // Shuffle the queue after the current track + const shuffledQueueWithNewSongs = + get().shuffle === PlayerShuffle.TRACK + ? [ + ...shuffledQueue.slice(0, shuffledIndex + 1), + ...shuffle([ + ...queueSongs.map((song) => song.uniqueId), + ...shuffledQueue.slice(shuffledIndex + 1), + ]), + ] + : []; + set((state) => { - state.queue.default = queueSongs; - state.current.time = 0; - state.current.player = 1; - state.current.index = 0; - state.current.shuffledIndex = 0; - state.current.song = queueSongs[0]; + state.queue.default = [ + ...queue.slice(0, currentIndex + 1), + ...queueSongs, + ...queue.slice(currentIndex + 1), + ]; + state.queue.shuffled = shuffledQueueWithNewSongs; }); } - } else if (type === Play.LAST) { - // Shuffle the queue after the current track - const shuffledQueueWithNewSongs = - get().shuffle === PlayerShuffle.TRACK - ? [ - ...shuffledQueue.slice(0, shuffledIndex + 1), - ...shuffle([ - ...queueSongs.map((song) => song.uniqueId), - ...shuffledQueue.slice(shuffledIndex + 1), - ]), - ] - : []; + return get().actions.getPlayerData(); + }, + autoNext: () => { + const isLastTrack = get().actions.checkIsLastTrack(); + const { repeat } = get(); + + if (repeat === PlayerRepeat.ONE) { + const nextIndex = get().current.index; + + set((state) => { + state.current.time = 0; + state.current.index = nextIndex; + state.current.shuffledIndex = get().current.shuffledIndex; + state.current.player = state.current.player === 1 ? 2 : 1; + state.current.song = get().queue.default[nextIndex]; + state.queue.previousNode = get().current.song; + }); + } else if (get().shuffle === PlayerShuffle.TRACK) { + const nextShuffleIndex = isLastTrack ? 0 : get().current.shuffledIndex + 1; + + const nextSong = get().queue.default.find( + (song) => song.uniqueId === get().queue.shuffled[nextShuffleIndex], + ); + + const nextSongIndex = get().queue.default.findIndex( + (song) => song.uniqueId === nextSong!.uniqueId, + ); + + set((state) => { + state.current.time = 0; + state.current.index = nextSongIndex!; + state.current.shuffledIndex = nextShuffleIndex; + state.current.player = state.current.player === 1 ? 2 : 1; + state.current.song = nextSong!; + state.queue.previousNode = get().current.song; + }); + } else { + const nextIndex = isLastTrack ? 0 : get().current.index + 1; + + set((state) => { + state.current.time = 0; + state.current.index = nextIndex; + state.current.player = state.current.player === 1 ? 2 : 1; + state.current.song = get().queue.default[nextIndex]; + state.queue.previousNode = get().current.song; + }); + } + + return get().actions.getPlayerData(); + }, + checkIsFirstTrack: () => { + const currentIndex = + get().shuffle === PlayerShuffle.TRACK + ? get().current.shuffledIndex + : get().current.index; + + return currentIndex === 0; + }, + checkIsLastTrack: () => { + const currentIndex = + get().shuffle === PlayerShuffle.TRACK + ? get().current.shuffledIndex + : get().current.index; + + return currentIndex === get().queue.default.length - 1; + }, + clearQueue: () => { set((state) => { - state.queue.default = [...get().queue.default, ...queueSongs]; - state.queue.shuffled = shuffledQueueWithNewSongs; + state.queue.default = []; + state.queue.shuffled = []; + state.queue.sorted = []; + state.current.index = 0; + state.current.shuffledIndex = 0; + state.current.player = 1; + state.current.song = undefined; }); - } else if (type === Play.NEXT) { + + return get().actions.getPlayerData(); + }, + getPlayerData: () => { const queue = get().queue.default; + const currentPlayer = get().current.player; + const { repeat } = get(); + const isLastTrack = get().actions.checkIsLastTrack(); + const isFirstTrack = get().actions.checkIsFirstTrack(); + + let player1; + let player2; + if (get().shuffle === PlayerShuffle.TRACK) { + const shuffledQueue = get().queue.shuffled; + const { shuffledIndex } = get().current; + const current = queue.find( + (song) => song.uniqueId === shuffledQueue[shuffledIndex], + ) as QueueSong; + + let nextSongIndex: number | undefined; + let previousSongIndex: number | undefined; + if (repeat === PlayerRepeat.ALL) { + if (isLastTrack) nextSongIndex = 0; + else nextSongIndex = shuffledIndex + 1; + + if (isFirstTrack) previousSongIndex = queue.length - 1; + else previousSongIndex = shuffledIndex - 1; + } + + if (repeat === PlayerRepeat.ONE) { + nextSongIndex = shuffledIndex; + previousSongIndex = shuffledIndex; + } + + if (repeat === PlayerRepeat.NONE) { + if (isLastTrack) nextSongIndex = undefined; + else nextSongIndex = shuffledIndex + 1; + + if (isFirstTrack) previousSongIndex = undefined; + else previousSongIndex = shuffledIndex - 1; + } + + const next = nextSongIndex + ? (queue.find( + (song) => song.uniqueId === shuffledQueue[nextSongIndex as number], + ) as QueueSong) + : undefined; + + const previous = queue.find( + (song) => song.uniqueId === shuffledQueue[shuffledIndex - 1], + ) as QueueSong; + + player1 = currentPlayer === 1 ? current : next; + player2 = currentPlayer === 1 ? next : current; + + return { + current: { + index: get().current.index, + nextIndex: nextSongIndex, + player: get().current.player, + previousIndex: previousSongIndex, + shuffledIndex: get().current.shuffledIndex, + song: get().current.song, + status: get().current.status, + }, + player1, + player2, + queue: { + current, + next, + previous, + }, + }; + } + const currentIndex = get().current.index; - // Shuffle the queue after the current track - const shuffledQueueWithNewSongs = - get().shuffle === PlayerShuffle.TRACK - ? [ - ...shuffledQueue.slice(0, shuffledIndex + 1), - ...shuffle([ - ...queueSongs.map((song) => song.uniqueId), - ...shuffledQueue.slice(shuffledIndex + 1), - ]), - ] - : []; - - set((state) => { - state.queue.default = [ - ...queue.slice(0, currentIndex + 1), - ...queueSongs, - ...queue.slice(currentIndex + 1), - ]; - state.queue.shuffled = shuffledQueueWithNewSongs; - }); - } - - return get().actions.getPlayerData(); - }, - autoNext: () => { - const isLastTrack = get().actions.checkIsLastTrack(); - const { repeat } = get(); - - if (repeat === PlayerRepeat.ONE) { - const nextIndex = get().current.index; - - set((state) => { - state.current.time = 0; - state.current.index = nextIndex; - state.current.shuffledIndex = get().current.shuffledIndex; - state.current.player = state.current.player === 1 ? 2 : 1; - state.current.song = get().queue.default[nextIndex]; - state.queue.previousNode = get().current.song; - }); - } else if (get().shuffle === PlayerShuffle.TRACK) { - const nextShuffleIndex = isLastTrack ? 0 : get().current.shuffledIndex + 1; - - const nextSong = get().queue.default.find( - (song) => song.uniqueId === get().queue.shuffled[nextShuffleIndex], - ); - - const nextSongIndex = get().queue.default.findIndex( - (song) => song.uniqueId === nextSong!.uniqueId, - ); - - set((state) => { - state.current.time = 0; - state.current.index = nextSongIndex!; - state.current.shuffledIndex = nextShuffleIndex; - state.current.player = state.current.player === 1 ? 2 : 1; - state.current.song = nextSong!; - state.queue.previousNode = get().current.song; - }); - } else { - const nextIndex = isLastTrack ? 0 : get().current.index + 1; - - set((state) => { - state.current.time = 0; - state.current.index = nextIndex; - state.current.player = state.current.player === 1 ? 2 : 1; - state.current.song = get().queue.default[nextIndex]; - state.queue.previousNode = get().current.song; - }); - } - - return get().actions.getPlayerData(); - }, - checkIsFirstTrack: () => { - const currentIndex = - get().shuffle === PlayerShuffle.TRACK - ? get().current.shuffledIndex - : get().current.index; - - return currentIndex === 0; - }, - checkIsLastTrack: () => { - const currentIndex = - get().shuffle === PlayerShuffle.TRACK - ? get().current.shuffledIndex - : get().current.index; - - return currentIndex === get().queue.default.length - 1; - }, - clearQueue: () => { - set((state) => { - state.queue.default = []; - state.queue.shuffled = []; - state.queue.sorted = []; - state.current.index = 0; - state.current.shuffledIndex = 0; - state.current.player = 1; - state.current.song = undefined; - }); - - return get().actions.getPlayerData(); - }, - getPlayerData: () => { - const queue = get().queue.default; - const currentPlayer = get().current.player; - const { repeat } = get(); - const isLastTrack = get().actions.checkIsLastTrack(); - const isFirstTrack = get().actions.checkIsFirstTrack(); - - let player1; - let player2; - if (get().shuffle === PlayerShuffle.TRACK) { - const shuffledQueue = get().queue.shuffled; - const { shuffledIndex } = get().current; - const current = queue.find( - (song) => song.uniqueId === shuffledQueue[shuffledIndex], - ) as QueueSong; - - let nextSongIndex: number | undefined; - let previousSongIndex: number | undefined; + let nextSongIndex; + let previousSongIndex; if (repeat === PlayerRepeat.ALL) { if (isLastTrack) nextSongIndex = 0; - else nextSongIndex = shuffledIndex + 1; + else nextSongIndex = currentIndex + 1; if (isFirstTrack) previousSongIndex = queue.length - 1; - else previousSongIndex = shuffledIndex - 1; + else previousSongIndex = currentIndex - 1; } if (repeat === PlayerRepeat.ONE) { - nextSongIndex = shuffledIndex; - previousSongIndex = shuffledIndex; + nextSongIndex = currentIndex; + previousSongIndex = currentIndex; } if (repeat === PlayerRepeat.NONE) { if (isLastTrack) nextSongIndex = undefined; - else nextSongIndex = shuffledIndex + 1; + else nextSongIndex = currentIndex + 1; if (isFirstTrack) previousSongIndex = undefined; - else previousSongIndex = shuffledIndex - 1; + else previousSongIndex = currentIndex - 1; } - const next = nextSongIndex - ? (queue.find( - (song) => song.uniqueId === shuffledQueue[nextSongIndex as number], - ) as QueueSong) - : undefined; + player1 = + currentPlayer === 1 + ? queue[currentIndex] + : nextSongIndex !== undefined + ? queue[nextSongIndex] + : undefined; - const previous = queue.find( - (song) => song.uniqueId === shuffledQueue[shuffledIndex - 1], - ) as QueueSong; - - player1 = currentPlayer === 1 ? current : next; - player2 = currentPlayer === 1 ? next : current; + player2 = + currentPlayer === 1 + ? nextSongIndex !== undefined + ? queue[nextSongIndex] + : undefined + : queue[currentIndex]; return { current: { - index: get().current.index, + index: currentIndex, nextIndex: nextSongIndex, player: get().current.player, previousIndex: previousSongIndex, @@ -315,488 +376,457 @@ export const usePlayerStore = create()( player1, player2, queue: { - current, - next, - previous, + current: queue[currentIndex], + next: nextSongIndex !== undefined ? queue[nextSongIndex] : undefined, + previous: queue[currentIndex - 1], }, }; - } + }, + getQueueData: () => { + const queue = get().queue.default; + return { + current: queue[get().current.index], + next: queue[get().current.index + 1], + previous: queue[get().current.index - 1], + }; + }, + incrementPlayCount: (ids) => { + const { default: queue } = get().queue; + const foundUniqueIds = []; - const currentIndex = get().current.index; - - let nextSongIndex; - let previousSongIndex; - if (repeat === PlayerRepeat.ALL) { - if (isLastTrack) nextSongIndex = 0; - else nextSongIndex = currentIndex + 1; - - if (isFirstTrack) previousSongIndex = queue.length - 1; - else previousSongIndex = currentIndex - 1; - } - - if (repeat === PlayerRepeat.ONE) { - nextSongIndex = currentIndex; - previousSongIndex = currentIndex; - } - - if (repeat === PlayerRepeat.NONE) { - if (isLastTrack) nextSongIndex = undefined; - else nextSongIndex = currentIndex + 1; - - if (isFirstTrack) previousSongIndex = undefined; - else previousSongIndex = currentIndex - 1; - } - - player1 = - currentPlayer === 1 - ? queue[currentIndex] - : nextSongIndex !== undefined - ? queue[nextSongIndex] - : undefined; - - player2 = - currentPlayer === 1 - ? nextSongIndex !== undefined - ? queue[nextSongIndex] - : undefined - : queue[currentIndex]; - - return { - current: { - index: currentIndex, - nextIndex: nextSongIndex, - player: get().current.player, - previousIndex: previousSongIndex, - shuffledIndex: get().current.shuffledIndex, - song: get().current.song, - status: get().current.status, - }, - player1, - player2, - queue: { - current: queue[currentIndex], - next: nextSongIndex !== undefined ? queue[nextSongIndex] : undefined, - previous: queue[currentIndex - 1], - }, - }; - }, - getQueueData: () => { - const queue = get().queue.default; - return { - current: queue[get().current.index], - next: queue[get().current.index + 1], - previous: queue[get().current.index - 1], - }; - }, - moveToBottomOfQueue: (uniqueIds) => { - const queue = get().queue.default; - - const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId)); - const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); - - const reorderedQueue = [...songsToStay, ...songsToMove]; - - const currentSongUniqueId = get().current.song?.uniqueId; - const newCurrentSongIndex = reorderedQueue.findIndex( - (song) => song.uniqueId === currentSongUniqueId, - ); - - set((state) => { - state.current.index = newCurrentSongIndex; - state.queue.default = reorderedQueue; - }); - - return get().actions.getPlayerData(); - }, - moveToTopOfQueue: (uniqueIds) => { - const queue = get().queue.default; - - const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId)); - const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); - - const reorderedQueue = [...songsToMove, ...songsToStay]; - - const currentSongUniqueId = get().current.song?.uniqueId; - const newCurrentSongIndex = reorderedQueue.findIndex( - (song) => song.uniqueId === currentSongUniqueId, - ); - - set((state) => { - state.current.index = newCurrentSongIndex; - state.queue.default = reorderedQueue; - }); - - return get().actions.getPlayerData(); - }, - next: () => { - const isLastTrack = get().actions.checkIsLastTrack(); - const { repeat } = get(); - - if (get().shuffle === PlayerShuffle.TRACK) { - const nextShuffleIndex = isLastTrack ? 0 : get().current.shuffledIndex + 1; - - const nextSong = get().queue.default.find( - (song) => song.uniqueId === get().queue.shuffled[nextShuffleIndex], - ); - - const nextSongIndex = get().queue.default.findIndex( - (song) => song.uniqueId === nextSong?.uniqueId, - ); - - set((state) => { - state.current.time = 0; - state.current.index = nextSongIndex!; - state.current.shuffledIndex = nextShuffleIndex; - state.current.player = 1; - state.current.song = nextSong!; - state.queue.previousNode = get().current.song; - }); - } else { - const nextIndex = - repeat === PlayerRepeat.ALL - ? isLastTrack - ? 0 - : get().current.index + 1 - : isLastTrack - ? get().current.index - : get().current.index + 1; - - set((state) => { - state.current.time = 0; - state.current.index = nextIndex; - state.current.player = 1; - state.current.song = get().queue.default[nextIndex]; - state.queue.previousNode = get().current.song; - }); - } - - return get().actions.getPlayerData(); - }, - pause: () => { - set((state) => { - state.current.status = PlayerStatus.PAUSED; - }); - }, - play: () => { - set((state) => { - state.current.status = PlayerStatus.PLAYING; - }); - }, - player1: () => { - return get().actions.getPlayerData().player1; - }, - player2: () => { - return get().actions.getPlayerData().player2; - }, - previous: () => { - const isFirstTrack = get().actions.checkIsFirstTrack(); - const { repeat } = get(); - - if (get().shuffle === PlayerShuffle.TRACK) { - const prevShuffleIndex = isFirstTrack ? 0 : get().current.shuffledIndex - 1; - - const prevSong = get().queue.default.find( - (song) => song.uniqueId === get().queue.shuffled[prevShuffleIndex], - ); - - const prevIndex = get().queue.default.findIndex( - (song) => song.uniqueId === prevSong?.uniqueId, - ); - - set((state) => { - state.current.time = 0; - state.current.index = prevIndex!; - state.current.shuffledIndex = prevShuffleIndex; - state.current.player = 1; - state.current.song = prevSong!; - state.queue.previousNode = get().current.song; - }); - } else { - let prevIndex: number; - if (repeat === PlayerRepeat.ALL) { - prevIndex = isFirstTrack ? get().queue.default.length - 1 : get().current.index - 1; - } else { - prevIndex = isFirstTrack ? 0 : get().current.index - 1; + for (const id of ids) { + const foundIndex = queue.findIndex((song) => song.id === id); + if (foundIndex !== -1) { + foundUniqueIds.push(queue[foundIndex].uniqueId); + set((state) => { + state.queue.default[foundIndex].playCount += 1; + }); + } } + const currentSongId = get().current.song?.id; + if (currentSongId && ids.includes(currentSongId)) { + set((state) => { + if (state.current.song) { + state.current.song.playCount += 1; + } + }); + } + + return foundUniqueIds; + }, + moveToBottomOfQueue: (uniqueIds) => { + const queue = get().queue.default; + + const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId)); + const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); + + const reorderedQueue = [...songsToStay, ...songsToMove]; + + const currentSongUniqueId = get().current.song?.uniqueId; + const newCurrentSongIndex = reorderedQueue.findIndex( + (song) => song.uniqueId === currentSongUniqueId, + ); + set((state) => { - state.current.time = 0; - state.current.index = prevIndex; - state.current.player = 1; - state.current.song = state.queue.default[state.current.index]; - state.queue.previousNode = get().current.song; + state.current.index = newCurrentSongIndex; + state.queue.default = reorderedQueue; }); - } - return get().actions.getPlayerData(); - }, - removeFromQueue: (uniqueIds) => { - const queue = get().queue.default; - - const newQueue = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); - - set((state) => { - state.queue.default = newQueue; - }); - - return get().actions.getPlayerData(); - }, - reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => { - // Don't move if dropping on top of a selected row - if (afterUniqueId && rowUniqueIds.includes(afterUniqueId)) { return get().actions.getPlayerData(); - } + }, + moveToTopOfQueue: (uniqueIds) => { + const queue = get().queue.default; - const queue = get().queue.default; - const currentSongUniqueId = get().current.song?.uniqueId; - const queueWithoutSelectedRows = queue.filter( - (song) => !rowUniqueIds.includes(song.uniqueId), - ); + const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId)); + const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); - const moveBeforeIndex = queueWithoutSelectedRows.findIndex( - (song) => song.uniqueId === afterUniqueId, - ); + const reorderedQueue = [...songsToMove, ...songsToStay]; - // AG-Grid does not provide node data when a row is moved to the bottom of the list - const reorderedQueue = afterUniqueId - ? [ - ...queueWithoutSelectedRows.slice(0, moveBeforeIndex), - ...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)), - ...queueWithoutSelectedRows.slice(moveBeforeIndex), - ] - : [ - ...queueWithoutSelectedRows, - ...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)), - ]; - - const currentSongIndex = reorderedQueue.findIndex( - (song) => song.uniqueId === currentSongUniqueId, - ); - - set({ - current: { ...get().current, index: currentSongIndex }, - queue: { ...get().queue, default: reorderedQueue }, - }); - - return get().actions.getPlayerData(); - }, - setCurrentIndex: (index) => { - if (get().shuffle === PlayerShuffle.TRACK) { - const foundSong = get().queue.default.find( - (song) => song.uniqueId === get().queue.shuffled[index], - ); - const foundIndex = get().queue.default.findIndex( - (song) => song.uniqueId === foundSong?.uniqueId, + const currentSongUniqueId = get().current.song?.uniqueId; + const newCurrentSongIndex = reorderedQueue.findIndex( + (song) => song.uniqueId === currentSongUniqueId, ); + set((state) => { - state.current.time = 0; - state.current.index = foundIndex!; - state.current.shuffledIndex = index; - state.current.player = 1; - state.current.song = foundSong!; - state.queue.previousNode = get().current.song; + state.current.index = newCurrentSongIndex; + state.queue.default = reorderedQueue; }); - } else { + + return get().actions.getPlayerData(); + }, + next: () => { + const isLastTrack = get().actions.checkIsLastTrack(); + const { repeat } = get(); + + if (get().shuffle === PlayerShuffle.TRACK) { + const nextShuffleIndex = isLastTrack ? 0 : get().current.shuffledIndex + 1; + + const nextSong = get().queue.default.find( + (song) => song.uniqueId === get().queue.shuffled[nextShuffleIndex], + ); + + const nextSongIndex = get().queue.default.findIndex( + (song) => song.uniqueId === nextSong?.uniqueId, + ); + + set((state) => { + state.current.time = 0; + state.current.index = nextSongIndex!; + state.current.shuffledIndex = nextShuffleIndex; + state.current.player = 1; + state.current.song = nextSong!; + state.queue.previousNode = get().current.song; + }); + } else { + const nextIndex = + repeat === PlayerRepeat.ALL + ? isLastTrack + ? 0 + : get().current.index + 1 + : isLastTrack + ? get().current.index + : get().current.index + 1; + + set((state) => { + state.current.time = 0; + state.current.index = nextIndex; + state.current.player = 1; + state.current.song = get().queue.default[nextIndex]; + state.queue.previousNode = get().current.song; + }); + } + + return get().actions.getPlayerData(); + }, + pause: () => { + set((state) => { + state.current.status = PlayerStatus.PAUSED; + }); + }, + play: () => { + set((state) => { + state.current.status = PlayerStatus.PLAYING; + }); + }, + player1: () => { + return get().actions.getPlayerData().player1; + }, + player2: () => { + return get().actions.getPlayerData().player2; + }, + previous: () => { + const isFirstTrack = get().actions.checkIsFirstTrack(); + const { repeat } = get(); + + if (get().shuffle === PlayerShuffle.TRACK) { + const prevShuffleIndex = isFirstTrack ? 0 : get().current.shuffledIndex - 1; + + const prevSong = get().queue.default.find( + (song) => song.uniqueId === get().queue.shuffled[prevShuffleIndex], + ); + + const prevIndex = get().queue.default.findIndex( + (song) => song.uniqueId === prevSong?.uniqueId, + ); + + set((state) => { + state.current.time = 0; + state.current.index = prevIndex!; + state.current.shuffledIndex = prevShuffleIndex; + state.current.player = 1; + state.current.song = prevSong!; + state.queue.previousNode = get().current.song; + }); + } else { + let prevIndex: number; + if (repeat === PlayerRepeat.ALL) { + prevIndex = isFirstTrack + ? get().queue.default.length - 1 + : get().current.index - 1; + } else { + prevIndex = isFirstTrack ? 0 : get().current.index - 1; + } + + set((state) => { + state.current.time = 0; + state.current.index = prevIndex; + state.current.player = 1; + state.current.song = state.queue.default[state.current.index]; + state.queue.previousNode = get().current.song; + }); + } + + return get().actions.getPlayerData(); + }, + removeFromQueue: (uniqueIds) => { + const queue = get().queue.default; + + const newQueue = queue.filter((song) => !uniqueIds.includes(song.uniqueId)); + + set((state) => { + state.queue.default = newQueue; + }); + + return get().actions.getPlayerData(); + }, + reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => { + // Don't move if dropping on top of a selected row + if (afterUniqueId && rowUniqueIds.includes(afterUniqueId)) { + return get().actions.getPlayerData(); + } + + const queue = get().queue.default; + const currentSongUniqueId = get().current.song?.uniqueId; + const queueWithoutSelectedRows = queue.filter( + (song) => !rowUniqueIds.includes(song.uniqueId), + ); + + const moveBeforeIndex = queueWithoutSelectedRows.findIndex( + (song) => song.uniqueId === afterUniqueId, + ); + + // AG-Grid does not provide node data when a row is moved to the bottom of the list + const reorderedQueue = afterUniqueId + ? [ + ...queueWithoutSelectedRows.slice(0, moveBeforeIndex), + ...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)), + ...queueWithoutSelectedRows.slice(moveBeforeIndex), + ] + : [ + ...queueWithoutSelectedRows, + ...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)), + ]; + + const currentSongIndex = reorderedQueue.findIndex( + (song) => song.uniqueId === currentSongUniqueId, + ); + + set({ + current: { ...get().current, index: currentSongIndex }, + queue: { ...get().queue, default: reorderedQueue }, + }); + + return get().actions.getPlayerData(); + }, + setCurrentIndex: (index) => { + if (get().shuffle === PlayerShuffle.TRACK) { + const foundSong = get().queue.default.find( + (song) => song.uniqueId === get().queue.shuffled[index], + ); + const foundIndex = get().queue.default.findIndex( + (song) => song.uniqueId === foundSong?.uniqueId, + ); + set((state) => { + state.current.time = 0; + state.current.index = foundIndex!; + state.current.shuffledIndex = index; + state.current.player = 1; + state.current.song = foundSong!; + state.queue.previousNode = get().current.song; + }); + } else { + set((state) => { + state.current.time = 0; + state.current.index = index; + state.current.player = 1; + state.current.song = state.queue.default[index]; + state.queue.previousNode = get().current.song; + }); + } + + return get().actions.getPlayerData(); + }, + setCurrentTime: (time) => { + set((state) => { + state.current.time = time; + }); + }, + setCurrentTrack: (uniqueId) => { + if (get().shuffle === PlayerShuffle.TRACK) { + const defaultIndex = get().queue.default.findIndex( + (song) => song.uniqueId === uniqueId, + ); + + const shuffledIndex = get().queue.shuffled.findIndex((id) => id === uniqueId); + + set((state) => { + state.current.time = 0; + state.current.index = defaultIndex; + state.current.shuffledIndex = shuffledIndex; + state.current.player = 1; + state.current.song = state.queue.default[defaultIndex]; + state.queue.previousNode = get().current.song; + }); + } else { + const defaultIndex = get().queue.default.findIndex( + (song) => song.uniqueId === uniqueId, + ); + + set((state) => { + state.current.time = 0; + state.current.index = defaultIndex; + state.current.player = 1; + state.current.song = state.queue.default[defaultIndex]; + state.queue.previousNode = get().current.song; + }); + } + + return get().actions.getPlayerData(); + }, + setFavorite: (ids, favorite) => { + const { default: queue } = get().queue; + const foundUniqueIds = []; + + for (const id of ids) { + const foundIndex = queue.findIndex((song) => song.id === id); + if (foundIndex !== -1) { + foundUniqueIds.push(queue[foundIndex].uniqueId); + set((state) => { + state.queue.default[foundIndex].userFavorite = favorite; + }); + } + } + + const currentSongId = get().current.song?.id; + if (currentSongId && ids.includes(currentSongId)) { + set((state) => { + if (state.current.song) { + state.current.song.userFavorite = favorite; + } + }); + } + + return foundUniqueIds; + }, + setMuted: (muted: boolean) => { + set((state) => { + state.muted = muted; + }); + }, + setRating: (ids, rating) => { + const { default: queue } = get().queue; + const foundUniqueIds = []; + + for (const id of ids) { + const foundIndex = queue.findIndex((song) => song.id === id); + if (foundIndex !== -1) { + foundUniqueIds.push(queue[foundIndex].uniqueId); + set((state) => { + state.queue.default[foundIndex].userRating = rating; + }); + } + } + + return foundUniqueIds; + }, + setRepeat: (type: PlayerRepeat) => { + set((state) => { + state.repeat = type; + }); + + return get().actions.getPlayerData(); + }, + setShuffle: (type: PlayerShuffle) => { + if (type === PlayerShuffle.NONE) { + set((state) => { + state.shuffle = type; + state.queue.shuffled = []; + }); + + return get().actions.getPlayerData(); + } + + const currentSongId = get().current.song?.uniqueId; + + const queueWithoutCurrentSong = get().queue.default.filter( + (song) => song.uniqueId !== currentSongId, + ); + + const shuffledSongIds = shuffle(queueWithoutCurrentSong).map((song) => song.uniqueId); + + set((state) => { + state.shuffle = type; + state.current.shuffledIndex = 0; + state.queue.shuffled = [currentSongId!, ...shuffledSongIds]; + }); + + return get().actions.getPlayerData(); + }, + setShuffledIndex: (index) => { set((state) => { state.current.time = 0; - state.current.index = index; + state.current.shuffledIndex = index; state.current.player = 1; state.current.song = state.queue.default[index]; state.queue.previousNode = get().current.song; }); - } - - return get().actions.getPlayerData(); - }, - setCurrentTime: (time) => { - set((state) => { - state.current.time = time; - }); - }, - setCurrentTrack: (uniqueId) => { - if (get().shuffle === PlayerShuffle.TRACK) { - const defaultIndex = get().queue.default.findIndex( - (song) => song.uniqueId === uniqueId, - ); - - const shuffledIndex = get().queue.shuffled.findIndex((id) => id === uniqueId); + return get().actions.getPlayerData(); + }, + setStore: (data) => { + set({ ...get(), ...data }); + }, + setVolume: (volume: number) => { set((state) => { - state.current.time = 0; - state.current.index = defaultIndex; - state.current.shuffledIndex = shuffledIndex; - state.current.player = 1; - state.current.song = state.queue.default[defaultIndex]; - state.queue.previousNode = get().current.song; + state.volume = volume; }); - } else { - const defaultIndex = get().queue.default.findIndex( - (song) => song.uniqueId === uniqueId, + }, + shuffleQueue: () => { + const queue = get().queue.default; + const shuffledQueue = shuffle(queue); + + const currentSongUniqueId = get().current.song?.uniqueId; + const newCurrentSongIndex = shuffledQueue.findIndex( + (song) => song.uniqueId === currentSongUniqueId, ); set((state) => { - state.current.time = 0; - state.current.index = defaultIndex; - state.current.player = 1; - state.current.song = state.queue.default[defaultIndex]; - state.queue.previousNode = get().current.song; - }); - } - - return get().actions.getPlayerData(); - }, - setFavorite: (ids, favorite) => { - const { default: queue } = get().queue; - const foundUniqueIds = []; - - for (const id of ids) { - const foundIndex = queue.findIndex((song) => song.id === id); - if (foundIndex !== -1) { - foundUniqueIds.push(queue[foundIndex].uniqueId); - set((state) => { - state.queue.default[foundIndex].userFavorite = favorite; - }); - } - } - - const currentSongId = get().current.song?.id; - if (currentSongId && ids.includes(currentSongId)) { - set((state) => { - if (state.current.song) { - state.current.song.userFavorite = favorite; - } - }); - } - - return foundUniqueIds; - }, - setMuted: (muted: boolean) => { - set((state) => { - state.muted = muted; - }); - }, - setRating: (ids, rating) => { - const { default: queue } = get().queue; - const foundUniqueIds = []; - - for (const id of ids) { - const foundIndex = queue.findIndex((song) => song.id === id); - if (foundIndex !== -1) { - foundUniqueIds.push(queue[foundIndex].uniqueId); - set((state) => { - state.queue.default[foundIndex].userRating = rating; - }); - } - } - - return foundUniqueIds; - }, - setRepeat: (type: PlayerRepeat) => { - set((state) => { - state.repeat = type; - }); - - return get().actions.getPlayerData(); - }, - setShuffle: (type: PlayerShuffle) => { - if (type === PlayerShuffle.NONE) { - set((state) => { - state.shuffle = type; - state.queue.shuffled = []; + state.current.index = newCurrentSongIndex; + state.queue.default = shuffledQueue; }); return get().actions.getPlayerData(); - } - - const currentSongId = get().current.song?.uniqueId; - - const queueWithoutCurrentSong = get().queue.default.filter( - (song) => song.uniqueId !== currentSongId, - ); - - const shuffledSongIds = shuffle(queueWithoutCurrentSong).map((song) => song.uniqueId); - - set((state) => { - state.shuffle = type; - state.current.shuffledIndex = 0; - state.queue.shuffled = [currentSongId!, ...shuffledSongIds]; - }); - - return get().actions.getPlayerData(); + }, }, - setShuffledIndex: (index) => { - set((state) => { - state.current.time = 0; - state.current.shuffledIndex = index; - state.current.player = 1; - state.current.song = state.queue.default[index]; - state.queue.previousNode = get().current.song; - }); - - return get().actions.getPlayerData(); + current: { + index: 0, + nextIndex: 0, + player: 1, + previousIndex: 0, + shuffledIndex: 0, + song: {} as QueueSong, + status: PlayerStatus.PAUSED, + time: 0, }, - setStore: (data) => { - set({ ...get(), ...data }); - }, - setVolume: (volume: number) => { - set((state) => { - state.volume = volume; - }); - }, - shuffleQueue: () => { - const queue = get().queue.default; - const shuffledQueue = shuffle(queue); - - const currentSongUniqueId = get().current.song?.uniqueId; - const newCurrentSongIndex = shuffledQueue.findIndex( - (song) => song.uniqueId === currentSongUniqueId, - ); - - set((state) => { - state.current.index = newCurrentSongIndex; - state.queue.default = shuffledQueue; - }); - - return get().actions.getPlayerData(); + muted: false, + queue: { + default: [], + played: [], + previousNode: {} as QueueSong, + shuffled: [], + sorted: [], }, + repeat: PlayerRepeat.NONE, + shuffle: PlayerShuffle.NONE, + volume: 50, + })), + { name: 'store_player' }, + ), + { + merge: (persistedState, currentState) => { + return merge(currentState, persistedState); }, - current: { - index: 0, - nextIndex: 0, - player: 1, - previousIndex: 0, - shuffledIndex: 0, - song: {} as QueueSong, - status: PlayerStatus.PAUSED, - time: 0, + name: 'store_player', + partialize: (state) => { + const notPersisted = ['queue', 'current']; + return Object.fromEntries( + Object.entries(state).filter(([key]) => !notPersisted.includes(key)), + ); }, - muted: false, - queue: { - default: [], - played: [], - previousNode: {} as QueueSong, - shuffled: [], - sorted: [], - }, - repeat: PlayerRepeat.NONE, - shuffle: PlayerShuffle.NONE, - volume: 50, - })), - { name: 'store_player' }, + version: 1, + }, ), - { - merge: (persistedState, currentState) => { - return merge(currentState, persistedState); - }, - name: 'store_player', - partialize: (state) => { - const notPersisted = ['queue', 'current']; - return Object.fromEntries( - Object.entries(state).filter(([key]) => !notPersisted.includes(key)), - ); - }, - version: 1, - }, ), ); @@ -872,3 +902,6 @@ export const useMuted = () => usePlayerStore((state) => state.muted); export const useSetQueueFavorite = () => usePlayerStore((state) => state.actions.setFavorite); export const useSetQueueRating = () => usePlayerStore((state) => state.actions.setRating); + +export const useIncrementQueuePlayCount = () => + usePlayerStore((state) => state.actions.incrementPlayCount); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 46e4005e..8dd5136e 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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()( muted: false, playButtonBehavior: Play.NOW, scrobble: { - enabled: false, + enabled: true, + scrobbleAtDuration: 240, scrobbleAtPercentage: 75, }, skipButtons: { @@ -220,7 +222,7 @@ export const useSettingsStore = create()( return merge(currentState, persistedState); }, name: 'store_settings', - version: 1, + version: 2, }, ), );