From deb69ef8ea268f93f9339dba315cbadfe7fb60ea Mon Sep 17 00:00:00 2001 From: Overdrive <34042434+Overdrivendev@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:22:56 +0100 Subject: [PATCH] Feature: Add sleep timer for end of current album (#2081) * Add logic for stopping playback at end of current album, unless in shuffle mode. --- src/i18n/locales/en.json | 1 + .../player/components/sleep-timer-button.tsx | 108 +++++++++++++++--- src/renderer/store/sleep-timer.store.ts | 26 ++++- 3 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a0191d0af..4e96df9ac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -699,6 +699,7 @@ "viewQueue": "View queue", "sleepTimer": "Sleep timer", "sleepTimer_endOfSong": "End of current song", + "sleepTimer_endOfAlbum": "End of current album", "sleepTimer_minutes": "{{count}} min", "sleepTimer_hours": "{{count}} hr", "sleepTimer_custom": "Custom", diff --git a/src/renderer/features/player/components/sleep-timer-button.tsx b/src/renderer/features/player/components/sleep-timer-button.tsx index 8dbdb2219..b116be291 100644 --- a/src/renderer/features/player/components/sleep-timer-button.tsx +++ b/src/renderer/features/player/components/sleep-timer-button.tsx @@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; -import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store'; +import { + usePlayerShuffle, + usePlayerStatus, + usePlayerStoreBase, +} from '/@/renderer/store/player.store'; import { useSleepTimerActions, useSleepTimerActive, @@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Popover } from '/@/shared/components/popover/popover'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; -import { PlayerStatus } from '/@/shared/types/types'; +import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; const PRESET_OPTIONS = [ { minutes: 0, mode: 'endOfSong' as const }, + { minutes: 0, mode: 'endOfAlbum' as const }, { minutes: 5, mode: 'timed' as const }, { minutes: 10, mode: 'timed' as const }, { minutes: 15, mode: 'timed' as const }, @@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string { const useSleepTimer = () => { const active = useSleepTimerActive(); const mode = useSleepTimerMode(); - const { cancelTimer, setRemaining } = useSleepTimerActions(); + const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions(); const { mediaPause } = usePlayer(); const mediaPauseRef = useRef(mediaPause); mediaPauseRef.current = mediaPause; + // End of album mode. Set the pauseOnNextSongEnd flag whenever the current track + // is the last one of the target album. + const evaluateEndOfAlbum = useCallback(() => { + const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData(); + + if (!currentSong) { + return; + } + + let target = useSleepTimerStore.getState().targetAlbumId; + + if (target === null) { + target = currentSong.albumId; + setTargetAlbumId(target); + } + + if (currentSong.albumId !== target) { + usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); + cancelTimer(); + return; + } + + const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId; + usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum); + }, [cancelTimer, setTargetAlbumId]); + const handleOnCurrentSongChange = useCallback(() => { if (!active) { return; @@ -65,8 +96,14 @@ const useSleepTimer = () => { if (mode === 'endOfSong') { cancelTimer(); mediaPauseRef.current(); + return; } - }, [active, mode, cancelTimer, mediaPauseRef]); + + // Cancel and pause song change in end-of-album mode + if (mode === 'endOfAlbum') { + evaluateEndOfAlbum(); + } + }, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]); const status = usePlayerStatus(); @@ -104,15 +141,32 @@ const useSleepTimer = () => { // mediaAutoNext returns PAUSED status when the current song ends. // This is a generic player mechanism — the web player handles it // without needing to know about the sleep timer. + // End-of-album mode: set the same flag conditionally, here we run + // the intial evaluation in case the timer was started while already + // on the last track of the album useEffect(() => { - if (!active || mode !== 'endOfSong') return; + if (!active) { + return; + } - usePlayerStoreBase.getState().setPauseOnNextSongEnd(true); + if (mode === 'endOfSong') { + usePlayerStoreBase.getState().setPauseOnNextSongEnd(true); - return () => { - usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); - }; - }, [active, mode]); + return () => { + usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); + }; + } + + if (mode === 'endOfAlbum') { + evaluateEndOfAlbum(); + + return () => { + usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); + }; + } + + return undefined; + }, [active, mode, evaluateEndOfAlbum]); }; export const SleepTimerHookInner = () => { @@ -135,8 +189,14 @@ export const SleepTimerButton = () => { const active = useSleepTimerActive(); const mode = useSleepTimerMode(); const remaining = useSleepTimerRemaining(); - const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions(); + const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } = + useSleepTimerActions(); const { mediaPause } = usePlayer(); + const shuffle = usePlayerShuffle(); + // Track level shuffle scatters and album across a play queue making 'end-of-album' + // meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album + // enabled there + const isTrackShuffle = shuffle === PlayerShuffle.TRACK; const [showCustom, setShowCustom] = useState(false); const [customHours, setCustomHours] = useState(0); @@ -151,13 +211,15 @@ export const SleepTimerButton = () => { (option: (typeof PRESET_OPTIONS)[number]) => { if (option.mode === 'endOfSong') { startEndOfSongTimer(); + } else if (option.mode === 'endOfAlbum') { + startEndOfAlbumTimer(); } else { startTimedTimer(option.minutes * 60); } setShowCustom(false); setOpened(false); }, - [startEndOfSongTimer, startTimedTimer], + [startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer], ); const handleCustomStart = useCallback(() => { @@ -178,6 +240,9 @@ export const SleepTimerButton = () => { if (option.mode === 'endOfSong') { return t('player.sleepTimer_endOfSong'); } + if (option.mode === 'endOfAlbum') { + return t('player.sleepTimer_endOfAlbum'); + } if (option.minutes >= 60) { return t('player.sleepTimer_hours', { count: option.minutes / 60, @@ -231,6 +296,10 @@ export const SleepTimerButton = () => { {t('player.sleepTimer_endOfSong')} + ) : mode === 'endOfAlbum' ? ( + + {t('player.sleepTimer_endOfAlbum')} + ) : ( {formatRemaining(remaining)} @@ -249,12 +318,17 @@ export const SleepTimerButton = () => { )} - {PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map( - (option, index) => ( + {PRESET_OPTIONS.filter( + (option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum', + ).map((option) => { + const disabled = option.mode === 'endOfAlbum' && isTrackShuffle; + + return ( - ), - )} + ); + })} diff --git a/src/renderer/store/sleep-timer.store.ts b/src/renderer/store/sleep-timer.store.ts index 1f88798b8..930f288ec 100644 --- a/src/renderer/store/sleep-timer.store.ts +++ b/src/renderer/store/sleep-timer.store.ts @@ -1,11 +1,13 @@ import { useShallow } from 'zustand/react/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; -export type SleepTimerMode = 'endOfSong' | 'timed'; +export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed'; interface SleepTimerActions { cancelTimer: () => void; setRemaining: (remaining: number) => void; + setTargetAlbumId: (albumId: null | string) => void; + startEndOfAlbumTimer: () => void; startEndOfSongTimer: () => void; startTimedTimer: (durationSeconds: number) => void; } @@ -17,6 +19,8 @@ interface SleepTimerState { mode: SleepTimerMode; /** Remaining seconds (only ticks while playing) */ remaining: number; + /** Album Id for song when mode activated */ + targetAlbumId: null | string; } export const useSleepTimerStore = createWithEqualityFn()( @@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn { + set({ targetAlbumId: albumId }); + }, + + startEndOfAlbumTimer: () => { + set({ + active: true, + mode: 'endOfAlbum', + remaining: 0, + targetAlbumId: null, + }); + }, + startEndOfSongTimer: () => { set({ active: true, mode: 'endOfSong', remaining: 0, + targetAlbumId: null, }); }, @@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn useShallow((s) => ({ cancelTimer: s.cancelTimer, setRemaining: s.setRemaining, + setTargetAlbumId: s.setTargetAlbumId, + startEndOfAlbumTimer: s.startEndOfAlbumTimer, startEndOfSongTimer: s.startEndOfSongTimer, startTimedTimer: s.startTimedTimer, })),