Feature: Add sleep timer for end of current album (#2081)

* Add logic for stopping playback at end of current album, unless in shuffle mode.
This commit is contained in:
Overdrive
2026-06-03 07:22:56 +01:00
committed by GitHub
parent 5ac0aaeec0
commit deb69ef8ea
3 changed files with 117 additions and 18 deletions
+1
View File
@@ -699,6 +699,7 @@
"viewQueue": "View queue", "viewQueue": "View queue",
"sleepTimer": "Sleep timer", "sleepTimer": "Sleep timer",
"sleepTimer_endOfSong": "End of current song", "sleepTimer_endOfSong": "End of current song",
"sleepTimer_endOfAlbum": "End of current album",
"sleepTimer_minutes": "{{count}} min", "sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr", "sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "Custom", "sleepTimer_custom": "Custom",
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; 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 { import {
useSleepTimerActions, useSleepTimerActions,
useSleepTimerActive, useSleepTimerActive,
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover'; import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
const PRESET_OPTIONS = [ const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const }, { minutes: 0, mode: 'endOfSong' as const },
{ minutes: 0, mode: 'endOfAlbum' as const },
{ minutes: 5, mode: 'timed' as const }, { minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const }, { minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const }, { minutes: 15, mode: 'timed' as const },
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
const useSleepTimer = () => { const useSleepTimer = () => {
const active = useSleepTimerActive(); const active = useSleepTimerActive();
const mode = useSleepTimerMode(); const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions(); const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
const { mediaPause } = usePlayer(); const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause); const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = 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(() => { const handleOnCurrentSongChange = useCallback(() => {
if (!active) { if (!active) {
return; return;
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
if (mode === 'endOfSong') { if (mode === 'endOfSong') {
cancelTimer(); cancelTimer();
mediaPauseRef.current(); 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(); const status = usePlayerStatus();
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
// mediaAutoNext returns PAUSED status when the current song ends. // mediaAutoNext returns PAUSED status when the current song ends.
// This is a generic player mechanism — the web player handles it // This is a generic player mechanism — the web player handles it
// without needing to know about the sleep timer. // 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(() => { useEffect(() => {
if (!active || mode !== 'endOfSong') return; if (!active) {
return;
}
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true); if (mode === 'endOfSong') {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
return () => { return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
}; };
}, [active, mode]); }
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
return undefined;
}, [active, mode, evaluateEndOfAlbum]);
}; };
export const SleepTimerHookInner = () => { export const SleepTimerHookInner = () => {
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
const active = useSleepTimerActive(); const active = useSleepTimerActive();
const mode = useSleepTimerMode(); const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining(); const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions(); const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
useSleepTimerActions();
const { mediaPause } = usePlayer(); 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 [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0); const [customHours, setCustomHours] = useState<number>(0);
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
(option: (typeof PRESET_OPTIONS)[number]) => { (option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') { if (option.mode === 'endOfSong') {
startEndOfSongTimer(); startEndOfSongTimer();
} else if (option.mode === 'endOfAlbum') {
startEndOfAlbumTimer();
} else { } else {
startTimedTimer(option.minutes * 60); startTimedTimer(option.minutes * 60);
} }
setShowCustom(false); setShowCustom(false);
setOpened(false); setOpened(false);
}, },
[startEndOfSongTimer, startTimedTimer], [startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
); );
const handleCustomStart = useCallback(() => { const handleCustomStart = useCallback(() => {
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
if (option.mode === 'endOfSong') { if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong'); return t('player.sleepTimer_endOfSong');
} }
if (option.mode === 'endOfAlbum') {
return t('player.sleepTimer_endOfAlbum');
}
if (option.minutes >= 60) { if (option.minutes >= 60) {
return t('player.sleepTimer_hours', { return t('player.sleepTimer_hours', {
count: option.minutes / 60, count: option.minutes / 60,
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
<Text c="primary" size="sm"> <Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong')} {t('player.sleepTimer_endOfSong')}
</Text> </Text>
) : mode === 'endOfAlbum' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfAlbum')}
</Text>
) : ( ) : (
<Text c="primary" fw="600" size="lg"> <Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)} {formatRemaining(remaining)}
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
</Flex> </Flex>
)} )}
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map( {PRESET_OPTIONS.filter(
(option, index) => ( (option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
).map((option) => {
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
return (
<Button <Button
disabled={disabled}
fullWidth fullWidth
justify="flex-start" justify="flex-start"
key={index} key={option.mode}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePreset(option); handlePreset(option);
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
> >
{getPresetLabel(option)} {getPresetLabel(option)}
</Button> </Button>
), );
)} })}
<Divider my="md" /> <Divider my="md" />
+25 -1
View File
@@ -1,11 +1,13 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed'; export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
interface SleepTimerActions { interface SleepTimerActions {
cancelTimer: () => void; cancelTimer: () => void;
setRemaining: (remaining: number) => void; setRemaining: (remaining: number) => void;
setTargetAlbumId: (albumId: null | string) => void;
startEndOfAlbumTimer: () => void;
startEndOfSongTimer: () => void; startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void; startTimedTimer: (durationSeconds: number) => void;
} }
@@ -17,6 +19,8 @@ interface SleepTimerState {
mode: SleepTimerMode; mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */ /** Remaining seconds (only ticks while playing) */
remaining: number; remaining: number;
/** Album Id for song when mode activated */
targetAlbumId: null | string;
} }
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()( export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: false, active: false,
mode: 'timed', mode: 'timed',
remaining: 0, remaining: 0,
targetAlbumId: null,
}); });
}, },
mode: 'timed', mode: 'timed',
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
set({ remaining }); set({ remaining });
}, },
setTargetAlbumId: (albumId: null | string) => {
set({ targetAlbumId: albumId });
},
startEndOfAlbumTimer: () => {
set({
active: true,
mode: 'endOfAlbum',
remaining: 0,
targetAlbumId: null,
});
},
startEndOfSongTimer: () => { startEndOfSongTimer: () => {
set({ set({
active: true, active: true,
mode: 'endOfSong', mode: 'endOfSong',
remaining: 0, remaining: 0,
targetAlbumId: null,
}); });
}, },
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: true, active: true,
mode: 'timed', mode: 'timed',
remaining: durationSeconds, remaining: durationSeconds,
targetAlbumId: null,
}); });
}, },
targetAlbumId: null,
}), }),
); );
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
useShallow((s) => ({ useShallow((s) => ({
cancelTimer: s.cancelTimer, cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining, setRemaining: s.setRemaining,
setTargetAlbumId: s.setTargetAlbumId,
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
startEndOfSongTimer: s.startEndOfSongTimer, startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer, startTimedTimer: s.startTimedTimer,
})), })),