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",
"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",
@@ -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<number>(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 = () => {
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong')}
</Text>
) : mode === 'endOfAlbum' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfAlbum')}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
</Flex>
)}
{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 (
<Button
disabled={disabled}
fullWidth
justify="flex-start"
key={index}
key={option.mode}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
>
{getPresetLabel(option)}
</Button>
),
)}
);
})}
<Divider my="md" />
+25 -1
View File
@@ -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<SleepTimerActions & SleepTimerState>()(
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: false,
mode: 'timed',
remaining: 0,
targetAlbumId: null,
});
},
mode: 'timed',
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
set({ remaining });
},
setTargetAlbumId: (albumId: null | string) => {
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<SleepTimerActions & Sleep
active: true,
mode: 'timed',
remaining: durationSeconds,
targetAlbumId: null,
});
},
targetAlbumId: null,
}),
);
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
setTargetAlbumId: s.setTargetAlbumId,
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),