From 5e79e385d9016e4ac359ad8b2ed0bcfbcb774d88 Mon Sep 17 00:00:00 2001 From: Damien Erambert Date: Wed, 15 Jul 2026 22:27:40 -0700 Subject: [PATCH] Support using alt+click on prev/next to jump to previous/next album in queue (#2220) --- src/i18n/locales/en.json | 4 + src/main/index.ts | 6 ++ src/preload/mpv-player.ts | 10 +++ .../hooks/use-main-player-listener.tsx | 16 +++- .../player/components/center-controls.tsx | 28 ++++++- .../mobile-fullscreen-player-controls.tsx | 4 +- .../player/components/mobile-playerbar.tsx | 4 +- .../player/context/player-context.tsx | 34 ++++---- .../player/hooks/use-media-session.ts | 4 +- .../player/hooks/use-playback-hotkeys.ts | 6 +- .../hotkeys/hotkey-manager-settings.tsx | 2 + src/renderer/store/player.store.ts | 83 +++++++++++++++++-- src/renderer/store/settings.store.ts | 6 ++ src/shared/api/utils.ts | 18 ++-- 14 files changed, 179 insertions(+), 46 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c98a63e7c..d48ff1482 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -678,6 +678,7 @@ "mute": "Mute", "muted": "Muted", "next": "Next", + "nextAlbum": "Alt+click for next album", "play": "Play", "playbackFetchCancel": "This is taking a while… close the notification to cancel", "playbackFetchInProgress": "Loading songs…", @@ -686,6 +687,7 @@ "playRandom": "Play random", "playSimilarSongs": "Play similar songs", "previous": "Previous", + "previousAlbum": "Alt+click for previous album", "queue_clear": "Clear queue", "queue_moveToBottom": "Move selected to bottom", "queue_moveToTop": "Move selected to top", @@ -927,10 +929,12 @@ "hotkey_listShowPlayingSong": "Show playing song in list", "hotkey_navigateHome": "Navigate to home", "hotkey_playbackNext": "Next track", + "hotkey_playbackNextAlbum": "Next album", "hotkey_playbackPause": "Pause", "hotkey_playbackPlay": "Play", "hotkey_playbackPlayPause": "Play / pause", "hotkey_playbackPrevious": "Previous track", + "hotkey_playbackPreviousAlbum": "Previous album", "hotkey_playbackStop": "Stop", "hotkey_rate0": "Rating clear", "hotkey_rate1": "Rating 1 star", diff --git a/src/main/index.ts b/src/main/index.ts index 5db8aa367..749bf89b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -875,10 +875,12 @@ enum BindingActions { LOCAL_SEARCH = 'localSearch', MUTE = 'volumeMute', NEXT = 'next', + NEXT_ALBUM = 'nextAlbum', PAUSE = 'pause', PLAY = 'play', PLAY_PAUSE = 'playPause', PREVIOUS = 'previous', + PREVIOUS_ALBUM = 'previousAlbum', SHUFFLE = 'toggleShuffle', SKIP_BACKWARD = 'skipBackward', SKIP_FORWARD = 'skipForward', @@ -906,11 +908,15 @@ const HOTKEY_ACTIONS: Record void> = { [BindingActions.LOCAL_SEARCH]: () => {}, [BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'), [BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'), + [BindingActions.NEXT_ALBUM]: () => + getMainWindow()?.webContents.send('renderer-player-next-album'), [BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'), [BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'), [BindingActions.PLAY_PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-play-pause'), [BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'), + [BindingActions.PREVIOUS_ALBUM]: () => + getMainWindow()?.webContents.send('renderer-player-previous-album'), [BindingActions.SHUFFLE]: () => getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'), [BindingActions.SKIP_BACKWARD]: () => diff --git a/src/preload/mpv-player.ts b/src/preload/mpv-player.ts index 5b1fd3fd9..a7d596632 100644 --- a/src/preload/mpv-player.ts +++ b/src/preload/mpv-player.ts @@ -118,6 +118,10 @@ const rendererNext = (cb: (data: PlayerData) => void) => { ipcRenderer.on('renderer-player-next', (_, data) => cb(data)); }; +const rendererNextAlbum = (cb: (data: PlayerData) => void) => { + ipcRenderer.on('renderer-player-next-album', (_, data) => cb(data)); +}; + const rendererPause = (cb: (data: PlayerData) => void) => { ipcRenderer.on('renderer-player-pause', (_, data) => cb(data)); }; @@ -134,6 +138,10 @@ const rendererPrevious = (cb: (data: PlayerData) => void) => { ipcRenderer.on('renderer-player-previous', (_, data) => cb(data)); }; +const rendererPreviousAlbum = (cb: (data: PlayerData) => void) => { + ipcRenderer.on('renderer-player-previous-album', (_, data) => cb(data)); +}; + const rendererStop = (cb: (data: PlayerData) => void) => { ipcRenderer.on('renderer-player-stop', (_, data) => cb(data)); }; @@ -215,11 +223,13 @@ export const mpvPlayerListener = { rendererError, rendererMpvReconnect, rendererNext, + rendererNextAlbum, rendererPause, rendererPlay, rendererPlayerFallback, rendererPlayPause, rendererPrevious, + rendererPreviousAlbum, rendererQuit, rendererSkipBackward, rendererSkipForward, diff --git a/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx b/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx index 089ce238c..dec73d25a 100644 --- a/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx @@ -56,13 +56,25 @@ export const useMainPlayerListener = () => { mpvPlayerListener.rendererNext(() => { if (!isRadioActive) { - mediaNext(); + mediaNext(false); + } + }); + + mpvPlayerListener.rendererNextAlbum(() => { + if (!isRadioActive) { + mediaNext(true); } }); mpvPlayerListener.rendererPrevious(() => { if (!isRadioActive) { - mediaPrevious(); + mediaPrevious(false); + } + }); + + mpvPlayerListener.rendererPreviousAlbum(() => { + if (!isRadioActive) { + mediaPrevious(true); } }); diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index ba57a935d..15661744a 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -21,6 +21,8 @@ import { useSkipButtons, } from '/@/renderer/store'; import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; export const CenterControls = () => { @@ -163,9 +165,18 @@ const PreviousButton = ({ disabled }: { disabled?: boolean }) => { } - onClick={mediaPrevious} + onClick={(e) => mediaPrevious(e.altKey)} tooltip={{ - label: t('player.previous'), + label: ( + + + {t('player.previous')} + + + {t('player.previousAlbum')} + + + ), openDelay: 0, }} variant="secondary" @@ -239,9 +250,18 @@ const NextButton = ({ disabled }: { disabled?: boolean }) => { } - onClick={mediaNext} + onClick={(e) => mediaNext(e.altKey)} tooltip={{ - label: t('player.next'), + label: ( + + + {t('player.next')} + + + {t('player.nextAlbum')} + + + ), openDelay: 0, }} variant="secondary" diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx index 5877c9251..6cb65414d 100644 --- a/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx +++ b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx @@ -31,7 +31,7 @@ export const MobileFullscreenPlayerControls = memo(
} - onClick={mediaPrevious} + onClick={(e) => mediaPrevious(e.altKey)} tooltip={{ label: t('player.previous'), openDelay: 0, @@ -71,7 +71,7 @@ export const MobileFullscreenPlayerControls = memo( /> } - onClick={mediaNext} + onClick={(e) => mediaNext(e.altKey)} tooltip={{ label: t('player.next'), openDelay: 0, diff --git a/src/renderer/features/player/components/mobile-playerbar.tsx b/src/renderer/features/player/components/mobile-playerbar.tsx index 12065af48..a2b2c50c4 100644 --- a/src/renderer/features/player/components/mobile-playerbar.tsx +++ b/src/renderer/features/player/components/mobile-playerbar.tsx @@ -203,7 +203,7 @@ export const MobilePlayerbar = () => { icon={} onClick={(e) => { e.stopPropagation(); - mediaPrevious(); + mediaPrevious(e.altKey); }} tooltip={{ label: t('player.previous'), @@ -223,7 +223,7 @@ export const MobilePlayerbar = () => { icon={} onClick={(e) => { e.stopPropagation(); - mediaNext(); + mediaNext(e.altKey); }} tooltip={{ label: t('player.next'), diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index fba178c5a..e59c5fa12 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -61,11 +61,11 @@ export interface PlayerContext { clearSelected: (items: QueueSong[]) => void; decreaseVolume: (amount: number) => void; increaseVolume: (amount: number) => void; - mediaNext: () => void; + mediaNext: (toNextAlbum: boolean) => void; mediaPause: () => void; mediaPlay: (id?: string) => void; mediaPlayByIndex: (index: number) => void; - mediaPrevious: () => void; + mediaPrevious: (toPreviousAlbum: boolean) => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: () => void; mediaSkipForward: () => void; @@ -579,13 +579,16 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { [storeActions], ); - const mediaNext = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaNext, { - category: LogCategory.PLAYER, - }); + const mediaNext = useCallback( + (toNextAlbum: boolean) => { + logFn.debug(logMsg[LogCategory.PLAYER].mediaNext, { + category: LogCategory.PLAYER, + }); - storeActions.mediaNext(); - }, [storeActions]); + storeActions.mediaNext(toNextAlbum); + }, + [storeActions], + ); const mediaPause = useCallback(() => { logFn.debug(logMsg[LogCategory.PLAYER].mediaPause, { @@ -619,13 +622,16 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { [storeActions], ); - const mediaPrevious = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaPrevious, { - category: LogCategory.PLAYER, - }); + const mediaPrevious = useCallback( + (toPreviousAlbum: boolean) => { + logFn.debug(logMsg[LogCategory.PLAYER].mediaPrevious, { + category: LogCategory.PLAYER, + }); - storeActions.mediaPrevious(); - }, [storeActions]); + storeActions.mediaPrevious(toPreviousAlbum); + }, + [storeActions], + ); const mediaStop = useCallback( (options?: { reset?: boolean }) => { diff --git a/src/renderer/features/player/hooks/use-media-session.ts b/src/renderer/features/player/hooks/use-media-session.ts index bd4b5d625..fdd70fb7b 100644 --- a/src/renderer/features/player/hooks/use-media-session.ts +++ b/src/renderer/features/player/hooks/use-media-session.ts @@ -100,7 +100,7 @@ export const useMediaSession = () => { return; } - playerRef.current.mediaNext(); + playerRef.current.mediaNext(false); }); mediaSession.setActionHandler('pause', () => { @@ -116,7 +116,7 @@ export const useMediaSession = () => { return; } - playerRef.current.mediaPrevious(); + playerRef.current.mediaPrevious(false); }); mediaSession.setActionHandler('seekto', (e) => { diff --git a/src/renderer/features/player/hooks/use-playback-hotkeys.ts b/src/renderer/features/player/hooks/use-playback-hotkeys.ts index aeaad61f1..5b18f82f8 100644 --- a/src/renderer/features/player/hooks/use-playback-hotkeys.ts +++ b/src/renderer/features/player/hooks/use-playback-hotkeys.ts @@ -14,11 +14,13 @@ export const usePlaybackHotkeys = () => { binding: (typeof bindings)[keyof typeof bindings]; handler: () => void; }> = [ - { binding: bindings.next, handler: () => player.mediaNext() }, + { binding: bindings.next, handler: () => player.mediaNext(false) }, + { binding: bindings.nextAlbum, handler: () => player.mediaNext(true) }, { binding: bindings.pause, handler: () => player.mediaPause() }, { binding: bindings.play, handler: () => player.mediaPlay() }, { binding: bindings.playPause, handler: () => player.mediaTogglePlayPause() }, - { binding: bindings.previous, handler: () => player.mediaPrevious() }, + { binding: bindings.previous, handler: () => player.mediaPrevious(false) }, + { binding: bindings.previousAlbum, handler: () => player.mediaPrevious(true) }, { binding: bindings.skipBackward, handler: () => player.mediaSkipBackward() }, { binding: bindings.skipForward, handler: () => player.mediaSkipForward() }, { binding: bindings.stop, handler: () => player.mediaStop() }, diff --git a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx index 176ac08ff..853c94581 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx @@ -70,6 +70,7 @@ const BINDINGS_MAP: Record = { context: 'navigateHome', }), next: i18n.t('setting.hotkey', { context: 'playbackNext' }), + nextAlbum: i18n.t('setting.hotkey', { context: 'playbackNextAlbum' }), pause: i18n.t('setting.hotkey', { context: 'playbackPause' }), play: i18n.t('setting.hotkey', { context: 'playbackPlay' }), playPause: i18n.t('setting.hotkey', { @@ -78,6 +79,7 @@ const BINDINGS_MAP: Record = { previous: i18n.t('setting.hotkey', { context: 'playbackPrevious', }), + previousAlbum: i18n.t('setting.hotkey', { context: 'playbackPreviousAlbum' }), rate0: i18n.t('setting.hotkey', { context: 'rate0' }), rate1: i18n.t('setting.hotkey', { context: 'rate1' }), rate2: i18n.t('setting.hotkey', { context: 'rate2' }), diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 6444a2a2e..565159eb4 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -52,11 +52,11 @@ interface Actions { isFirstTrackInQueue: () => boolean; isLastTrackInQueue: () => boolean; mediaAutoNext: () => PlayerData; - mediaNext: () => void; + mediaNext: (toNextAlbum: boolean) => void; mediaPause: () => void; mediaPlay: (id?: string) => void; mediaPlayByIndex: (index: number) => void; - mediaPrevious: () => void; + mediaPrevious: (toPreviousAlbum: boolean) => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: (offset?: number) => void; mediaSkipForward: (offset?: number) => void; @@ -1044,7 +1044,7 @@ export const usePlayerStoreBase = createWithEqualityFn()( status: newStatus, }; }, - mediaNext: () => { + mediaNext: (toNextAlbum) => { const state = get(); const currentIndex = state.player.index; const player = state.player; @@ -1072,11 +1072,30 @@ export const usePlayerStoreBase = createWithEqualityFn()( return; } - const { nextIndex, shouldStop } = calculateNextIndex( - currentIndex, - playbackLength, - repeat, - ); + const nextIndexProps = calculateNextIndex(currentIndex, playbackLength, repeat); + let { nextIndex } = nextIndexProps; + const { shouldStop } = nextIndexProps; + + if (toNextAlbum && !shouldStop) { + const currentItem = queue.items[currentIndex]; + const [start, end] = findLastAlbumRange(queue.items); + const isOnLastAlbum = start <= currentIndex && currentIndex <= end; + if (isOnLastAlbum) { + const nextIndexWithNextAlbum = queue.items.findIndex( + (i) => i.albumId !== currentItem.albumId, + ); + + nextIndex = nextIndexWithNextAlbum; + } else { + const queueStartingFromCurrent = queue.items.slice(currentIndex); + const nextIndexWithNextAlbum = queueStartingFromCurrent.findIndex( + (i) => i.albumId !== currentItem.albumId, + ); + nextIndex = + nextIndexWithNextAlbum + + (queue.items.length - queueStartingFromCurrent.length); + } + } if (shouldStop) { set((state) => { @@ -1194,7 +1213,7 @@ export const usePlayerStoreBase = createWithEqualityFn()( }); } }, - mediaPrevious: () => { + mediaPrevious: (toPreviousAlbum) => { const currentIndex = get().player.index; const player = get().player; const queue = get().getQueueOrder(); @@ -1217,6 +1236,11 @@ export const usePlayerStoreBase = createWithEqualityFn()( } else if (player.repeat === PlayerRepeat.NONE && isFirstTrack) { // Repeat none: stay on first track if already there previousIndex = currentIndex; + } else if (toPreviousAlbum) { + previousIndex = Math.max( + 0, + findIndexWithPreviousAlbum(queue.items, currentIndex), + ); } else { // Otherwise, go to previous track previousIndex = Math.max(0, currentIndex - 1); @@ -2262,6 +2286,47 @@ function cleanupOrphanedSongs(state: any): boolean { return hasOrphans; } +function findIndexWithPreviousAlbum(queueItems: QueueSong[], currentIndex: number) { + const queueBeforeCurrent = queueItems.slice(0, currentIndex); + const currentItem = queueItems[currentIndex]; + + const previousAlbumIdInQueue = queueBeforeCurrent.findLast( + (i) => i.albumId !== currentItem.albumId, + )?.albumId; + + let prevIndex = -1; + + if (previousAlbumIdInQueue) { + for (let index = queueBeforeCurrent.length - 1; index > -1; index--) { + const element = queueBeforeCurrent[index]; + if (element.albumId === previousAlbumIdInQueue) { + prevIndex = index; + } + if (prevIndex > -1 && element.albumId !== previousAlbumIdInQueue) { + break; + } + } + } + + return prevIndex; +} + +function findLastAlbumRange(queueItems: QueueSong[]) { + const lastAlbumId = queueItems.at(-1)?.albumId; + const rangeEnd = queueItems.length - 1; + let rangeStart = rangeEnd; + + for (let index = rangeEnd; index > -1; index--) { + const element = queueItems[index]; + rangeStart = index; + if (element.albumId !== lastAlbumId) { + break; + } + } + + return [rangeStart + 1, rangeEnd]; +} + function parseUniqueSeekToTimestamp(timestamp: string) { return Number(timestamp.split('-')[0]); } diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 114cad413..4813cf46d 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -145,10 +145,12 @@ const BindingActionsSchema = z.enum([ 'volumeMute', 'navigateHome', 'next', + 'nextAlbum', 'pause', 'play', 'playPause', 'previous', + 'previousAlbum', 'rate0', 'rate1', 'rate2', @@ -847,10 +849,12 @@ export enum BindingActions { MUTE = 'volumeMute', NAVIGATE_HOME = 'navigateHome', NEXT = 'next', + NEXT_ALBUM = 'nextAlbum', PAUSE = 'pause', PLAY = 'play', PLAY_PAUSE = 'playPause', PREVIOUS = 'previous', + PREVIOUS_ALBUM = 'previousAlbum', RATE_0 = 'rate0', RATE_1 = 'rate1', RATE_2 = 'rate2', @@ -1322,10 +1326,12 @@ const initialState: SettingsState = { localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false }, navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false }, next: { allowGlobal: true, hotkey: '', isGlobal: false }, + nextAlbum: { allowGlobal: true, hotkey: '', isGlobal: false }, pause: { allowGlobal: true, hotkey: '', isGlobal: false }, play: { allowGlobal: true, hotkey: '', isGlobal: false }, playPause: { allowGlobal: true, hotkey: 'space', isGlobal: false }, previous: { allowGlobal: true, hotkey: '', isGlobal: false }, + previousAlbum: { allowGlobal: true, hotkey: '', isGlobal: false }, rate0: { allowGlobal: true, hotkey: '', isGlobal: false }, rate1: { allowGlobal: true, hotkey: '', isGlobal: false }, rate2: { allowGlobal: true, hotkey: '', isGlobal: false }, diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index bfcaccfbf..a344bbe86 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -342,31 +342,31 @@ export const sortSongsByFetchedOrder = ( // Group songs by the fetched ID they belong to const songsByFetchedId = new Map(); + const fetchedIdsSet = new Set(fetchedIds); for (const song of songs) { let matchedId: string | undefined; switch (itemType) { case LibraryItem.ALBUM: - matchedId = fetchedIds.find((id) => song.albumId === id); + matchedId = fetchedIdsSet.has(song.albumId) ? song.albumId : undefined; break; case LibraryItem.ALBUM_ARTIST: - matchedId = fetchedIds.find((id) => - song.albumArtists.some((artist) => artist.id === id), - ); + matchedId = song.albumArtists.find((a) => fetchedIdsSet.has(a.id))?.id; break; case LibraryItem.ARTIST: - matchedId = fetchedIds.find((id) => - song.artists.some((artist) => artist.id === id), - ); + matchedId = song.artists.find((a) => fetchedIdsSet.has(a.id))?.id; break; case LibraryItem.GENRE: - matchedId = fetchedIds.find((id) => song.genres.some((genre) => genre.id === id)); + matchedId = song.genres.find((a) => fetchedIdsSet.has(a.id))?.id; break; case LibraryItem.PLAYLIST: // For playlists, we might need to track which playlist each song came from // This is a simplified approach - you may need to adjust based on your data structure - matchedId = fetchedIds.find((id) => song.playlistItemId === id); + matchedId = + song.playlistItemId && fetchedIdsSet.has(song.playlistItemId) + ? song.playlistItemId + : undefined; break; default: break;