From e00aeb2b678a22ba8f97b5a02fd840076a2ee306 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:25:25 -0700 Subject: [PATCH] enable notify, simplify use-scrobble with types, remove unused check --- src/i18n/locales/en.json | 3 ++ .../features/player/hooks/use-scrobble.ts | 36 +++++++++----- .../components/playback/scrobble-settings.tsx | 47 +++++++++++++++++++ src/renderer/store/settings.store.ts | 2 + 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 18a124fc..a8caec4b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -171,6 +171,7 @@ "loginRateError": "too many login attempts, please try again in a few seconds", "mpvRequired": "MPV required", "networkError": "a network error occurred", + "notificationDenied": "permissions for notifications were denied. this setting has no effect", "openError": "could not open file", "playbackError": "an error occurred when trying to play the media", "remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server", @@ -605,6 +606,8 @@ "lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried", "lyricOffset": "lyric offset (ms)", "lyricOffset_description": "offset the lyric by the specified amount of milliseconds", + "notify": "enable song notifications", + "notify_description": "show notifications when changing the current song", "minimizeToTray": "minimize to tray", "minimizeToTray_description": "minimize the application to the system tray", "minimumScrobblePercentage": "minimum scrobble duration (percentage)", diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index 19af0b67..88d798b9 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -34,6 +34,8 @@ Progress Events (Jellyfin only): - Sends the 'progress' scrobble event on an interval */ +type SongEvent = [QueueSong | undefined, number, 1 | 2]; + const checkScrobbleConditions = (args: { scrobbleAtDurationMs: number; scrobbleAtPercentage: number; @@ -86,10 +88,21 @@ export const useScrobble = () => { const progressIntervalId = useRef>(null); const songChangeTimeoutId = useRef>(null); const handleScrobbleFromSongChange = useCallback( - ( - current: (number | QueueSong | undefined)[], - previous: (number | QueueSong | undefined)[], - ) => { + (current: SongEvent, previous: SongEvent) => { + if (scrobbleSettings?.notify && current[0]) { + const currentSong = current[0]; + + const artists = + currentSong.artists?.length > 0 + ? currentSong.artists.map((artist) => artist.name).join(', ') + : currentSong.artistName; + + new Notification(`Now playing ${currentSong.name}`, { + body: `by ${artists} on ${currentSong.album}`, + icon: currentSong.imageUrl || undefined, + }); + } + if (!isScrobbleEnabled) return; if (progressIntervalId.current) { @@ -98,8 +111,8 @@ export const useScrobble = () => { } // const currentSong = current[0] as QueueSong | undefined; - const previousSong = previous[0] as QueueSong; - const previousSongTimeSec = previous[1] as number; + const previousSong = previous[0]; + const previousSongTimeSec = previous[1]; // Send completion scrobble when song changes and a previous song exists if (previousSong?.id) { @@ -135,7 +148,7 @@ export const useScrobble = () => { // 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; + const currentSong = current[0]; // Get the current status from the state, not variable. This is because // of a timing issue where, when playing the first track, the first // event is song, and then the event is play @@ -169,9 +182,10 @@ export const useScrobble = () => { }, 2000); }, [ - isScrobbleEnabled, + scrobbleSettings?.notify, scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage, + isScrobbleEnabled, isCurrentSongScrobbled, sendScrobble, handleScrobbleFromSeek, @@ -332,7 +346,7 @@ export const useScrobble = () => { useEffect(() => { const unsubSongChange = usePlayerStore.subscribe( - (state) => [state.current.song, state.current.time, state.current.player], + (state): SongEvent => [state.current.song, state.current.time, state.current.player], handleScrobbleFromSongChange, { // We need the current time to check the scrobble condition, but we only want to @@ -345,10 +359,8 @@ export const useScrobble = () => { equalityFn: (a, b) => // compute whether the song changed (a[0] as QueueSong)?.uniqueId === (b[0] as QueueSong)?.uniqueId && - // compute whether the position changed. This should imply 1 - a[2] === b[2] && // compute whether the same player: relevant for repeat one and repeat all (one track) - a[3] === b[3], + a[2] === b[2], }, ); diff --git a/src/renderer/features/settings/components/playback/scrobble-settings.tsx b/src/renderer/features/settings/components/playback/scrobble-settings.tsx index e8659155..0f995f1b 100644 --- a/src/renderer/features/settings/components/playback/scrobble-settings.tsx +++ b/src/renderer/features/settings/components/playback/scrobble-settings.tsx @@ -8,6 +8,7 @@ import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/ import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Slider } from '/@/shared/components/slider/slider'; import { Switch } from '/@/shared/components/switch/switch'; +import { toast } from '/@/shared/components/toast/toast'; export const ScrobbleSettings = () => { const { t } = useTranslation(); @@ -95,6 +96,52 @@ export const ScrobbleSettings = () => { }), title: t('setting.minimumScrobbleSeconds', { postProcess: 'sentenceCase' }), }, + { + control: ( + { + if (Notification.permission === 'denied') { + toast.error({ + message: t('error.notificationDenied', { + postProcess: 'sentenceCase', + }), + }); + return; + } + + if (Notification.permission !== 'granted') { + const permissions = await Notification.requestPermission(); + if (permissions !== 'granted') { + toast.error({ + message: t('error.notificationDenied', { + postProcess: 'sentenceCase', + }), + }); + return; + } + } + + setSettings({ + playback: { + ...settings, + scrobble: { + ...settings.scrobble, + notify: e.currentTarget.checked, + }, + }, + }); + }} + /> + ), + description: t('setting.notify', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !('Notification' in window), + title: t('setting.notify', { postProcess: 'sentenceCase' }), + }, ]; return ; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 7d85cfbc..cec445ef 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -285,6 +285,7 @@ export interface SettingsState { preservePitch: boolean; scrobble: { enabled: boolean; + notify: boolean; scrobbleAtDuration: number; scrobbleAtPercentage: number; }; @@ -479,6 +480,7 @@ const initialState: SettingsState = { preservePitch: true, scrobble: { enabled: true, + notify: false, scrobbleAtDuration: 240, scrobbleAtPercentage: 75, },