Add sleep timer to player bar (#1671)

* feat: add sleep timer to player bar

- Add sleep timer button in player bar right controls
- Preset options: End of song, 5/10/15/30/45 min, 1 hr, 2 hrs
- Custom timer with HH:MM:SS input fields
- Timer only counts down while music is playing
- Timer pauses playback when it expires
- End-of-song mode pauses at the next track change
- Uses theme-aware styling (--theme-colors-surface)
- Add sleepTimer/sleepTimerOff icons (LuTimer/LuTimerOff)
- Add i18n strings for sleep timer UI

---------

Co-authored-by: York <york@BonecharMac.local>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
York
2026-02-11 13:19:37 +08:00
committed by GitHub
parent 496eab7d09
commit cc8cb4f4f1
6 changed files with 431 additions and 1 deletions
Regular → Executable
+10 -1
View File
@@ -667,7 +667,16 @@
"trackRadio": "track radio",
"unfavorite": "unfavorite",
"pause": "pause",
"viewQueue": "view queue"
"viewQueue": "view queue",
"sleepTimer": "sleep timer",
"sleepTimer_endOfSong": "end of current song",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "custom",
"sleepTimer_off": "off",
"sleepTimer_timeRemaining": "{{time}} remaining",
"sleepTimer_setCustom": "set timer",
"sleepTimer_cancel": "cancel timer"
},
"queryBuilder": {
"standardTags": "standard tags",
@@ -7,6 +7,7 @@ import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
import { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
@@ -48,6 +49,7 @@ export const AudioPlayers = () => {
return (
<>
<SleepTimerHook />
<ScrobbleHook />
<PowerSaveBlockerHook />
<DiscordRpcHook />
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
@@ -72,6 +73,7 @@ export const RightControls = () => {
<AutoDJButton />
</Group>
<Group align="center" gap="xs" wrap="nowrap">
<SleepTimerButton />
<PlayerConfig />
<LyricsButton />
<FavoriteButton />
@@ -0,0 +1,344 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
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 {
useSleepTimerActions,
useSleepTimerActive,
useSleepTimerMode,
useSleepTimerRemaining,
useSleepTimerStore,
} from '/@/renderer/store/sleep-timer.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
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';
const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const },
{ minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const },
{ minutes: 30, mode: 'timed' as const },
{ minutes: 45, mode: 'timed' as const },
{ minutes: 60, mode: 'timed' as const },
{ minutes: 120, mode: 'timed' as const },
];
function formatRemaining(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
const useSleepTimer = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
const handleOnCurrentSongChange = useCallback(() => {
if (!active) {
return;
}
// Cancel and pause on song change in end-of-song mode
if (mode === 'endOfSong') {
cancelTimer();
mediaPauseRef.current();
}
}, [active, mode, cancelTimer, mediaPauseRef]);
const status = usePlayerStatus();
const handleOnPlayerProgress = useCallback(() => {
if (!active) {
return;
}
if (status !== PlayerStatus.PLAYING) {
return;
}
// Count down in timed mode
if (mode === 'timed') {
const remaining = useSleepTimerStore.getState().remaining;
if (remaining <= 0) {
cancelTimer();
mediaPauseRef.current();
} else {
setRemaining(Math.max(0, remaining - 1));
}
}
}, [active, cancelTimer, mode, setRemaining, status]);
usePlayerEvents(
{
onCurrentSongChange: handleOnCurrentSongChange,
onPlayerProgress: handleOnPlayerProgress,
},
[handleOnCurrentSongChange, handleOnPlayerProgress],
);
// End-of-song mode: subscribe to player index changes
useEffect(() => {
if (!active || mode !== 'endOfSong') return;
const initialIndex = usePlayerStoreBase.getState().player.index;
const unsub = usePlayerStoreBase.subscribe(
(state) => state.player.index,
(index) => {
if (index !== initialIndex) {
cancelTimer();
mediaPauseRef.current();
}
},
);
return () => unsub();
}, [active, mode, cancelTimer]);
};
export const SleepTimerHookInner = () => {
useSleepTimer();
return null;
};
export const SleepTimerHook = () => {
const active = useSleepTimerActive();
if (!active) {
return null;
}
return React.createElement(SleepTimerHookInner);
};
export const SleepTimerButton = () => {
const { t } = useTranslation();
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0);
const [customMinutes, setCustomMinutes] = useState<number>(20);
const [customSeconds, setCustomSeconds] = useState<number>(0);
const [opened, setOpened] = useState(false);
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
const handlePreset = useCallback(
(option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
startEndOfSongTimer();
} else {
startTimedTimer(option.minutes * 60);
}
setShowCustom(false);
setOpened(false);
},
[startEndOfSongTimer, startTimedTimer],
);
const handleCustomStart = useCallback(() => {
const totalSeconds = customHours * 3600 + customMinutes * 60 + customSeconds;
if (totalSeconds > 0) {
startTimedTimer(totalSeconds);
setShowCustom(false);
setOpened(false);
}
}, [customHours, customMinutes, customSeconds, startTimedTimer]);
const handleCancel = useCallback(() => {
cancelTimer();
setShowCustom(false);
}, [cancelTimer]);
const getPresetLabel = (option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong', { postProcess: 'sentenceCase' });
}
if (option.minutes >= 60) {
return t('player.sleepTimer_hours', {
count: option.minutes / 60,
postProcess: 'sentenceCase',
});
}
return t('player.sleepTimer_minutes', {
count: option.minutes,
postProcess: 'sentenceCase',
});
};
return (
<Popover onChange={setOpened} opened={opened} position="top" width={260}>
<Popover.Target>
<ActionIcon
icon={active ? 'sleepTimer' : 'sleepTimerOff'}
iconProps={{
color: active ? 'primary' : undefined,
size: 'lg',
}}
onClick={(e) => {
e.stopPropagation();
setOpened((prev) => !prev);
}}
size="sm"
tooltip={{
label: t('player.sleepTimer', { postProcess: 'titleCase' }),
openDelay: 0,
}}
variant="subtle"
/>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" p="xs">
<Text fw="600" size="sm" ta="center">
{t('player.sleepTimer', { postProcess: 'titleCase' })}
</Text>
{active && (
<Flex
align="center"
direction="column"
gap={4}
mb="xs"
style={{
background: 'var(--theme-colors-surface)',
borderRadius: 'var(--theme-radius-md)',
padding: 'var(--theme-spacing-sm) var(--theme-spacing-md)',
}}
>
{mode === 'endOfSong' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong', {
postProcess: 'sentenceCase',
})}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
</Text>
)}
<Button
onClick={(e) => {
e.stopPropagation();
handleCancel();
}}
size="compact-xs"
variant="subtle"
>
{t('player.sleepTimer_cancel', { postProcess: 'titleCase' })}
</Button>
</Flex>
)}
{PRESET_OPTIONS.map((option, index) => (
<Button
fullWidth
justify="flex-start"
key={index}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
}}
size="xs"
variant="subtle"
>
{getPresetLabel(option)}
</Button>
))}
{!showCustom ? (
<Button
fullWidth
justify="flex-start"
onClick={(e) => {
e.stopPropagation();
setShowCustom(true);
}}
size="xs"
variant="subtle"
>
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
</Button>
) : (
<Stack gap="xs">
<Group gap={4} wrap="nowrap">
<NumberInput
max={23}
min={0}
onChange={(val) => setCustomHours(Number(val) || 0)}
placeholder="hr"
size="xs"
value={customHours}
/>
<Text>:</Text>
<NumberInput
max={59}
min={0}
onChange={(val) => setCustomMinutes(Number(val) || 0)}
placeholder="min"
size="xs"
value={customMinutes}
/>
<Text>:</Text>
<NumberInput
max={59}
min={0}
onChange={(val) => setCustomSeconds(Number(val) || 0)}
placeholder="sec"
size="xs"
value={customSeconds}
/>
</Group>
<Group gap="xs" grow>
<Button
onClick={(e) => {
e.stopPropagation();
handleCustomStart();
}}
size="xs"
variant="filled"
>
{t('player.sleepTimer_setCustom', { postProcess: 'titleCase' })}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
setShowCustom(false);
}}
size="xs"
variant="default"
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
+69
View File
@@ -0,0 +1,69 @@
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed';
interface SleepTimerActions {
cancelTimer: () => void;
setRemaining: (remaining: number) => void;
startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void;
}
interface SleepTimerState {
/** Whether the timer is currently active */
active: boolean;
/** The mode of the timer */
mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */
remaining: number;
}
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
(set) => ({
active: false,
cancelTimer: () => {
set({
active: false,
mode: 'timed',
remaining: 0,
});
},
mode: 'timed',
remaining: 0,
setRemaining: (remaining: number) => {
set({ remaining });
},
startEndOfSongTimer: () => {
set({
active: true,
mode: 'endOfSong',
remaining: 0,
});
},
startTimedTimer: (durationSeconds: number) => {
set({
active: true,
mode: 'timed',
remaining: durationSeconds,
});
},
}),
);
// Selectors
export const useSleepTimerActive = () => useSleepTimerStore((s) => s.active);
export const useSleepTimerMode = () => useSleepTimerStore((s) => s.mode);
export const useSleepTimerRemaining = () => useSleepTimerStore((s) => s.remaining);
export const useSleepTimerActions = () =>
useSleepTimerStore(
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),
);
+4
View File
@@ -105,6 +105,8 @@ import {
LuStepForward,
LuSun,
LuTable,
LuTimer,
LuTimerOff,
LuTriangleAlert,
LuUpload,
LuUser,
@@ -237,6 +239,8 @@ export const AppIcon = {
share: LuShare2,
signIn: LuLogIn,
signOut: LuLogOut,
sleepTimer: LuTimer,
sleepTimerOff: LuTimerOff,
sort: LuArrowUpDown,
sortAsc: LuArrowUpNarrowWide,
sortDesc: LuArrowDownWideNarrow,