From cc8cb4f4f116320cb39552e1cd8714440bfe08e7 Mon Sep 17 00:00:00 2001 From: York Date: Wed, 11 Feb 2026 13:19:37 +0800 Subject: [PATCH] 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 Co-authored-by: jeffvli --- src/i18n/locales/en.json | 11 +- .../player/components/audio-players.tsx | 2 + .../player/components/right-controls.tsx | 2 + .../player/components/sleep-timer-button.tsx | 344 ++++++++++++++++++ src/renderer/store/sleep-timer.store.ts | 69 ++++ src/shared/components/icon/icon.tsx | 4 + 6 files changed, 431 insertions(+), 1 deletion(-) mode change 100644 => 100755 src/i18n/locales/en.json mode change 100644 => 100755 src/renderer/features/player/components/right-controls.tsx create mode 100644 src/renderer/features/player/components/sleep-timer-button.tsx create mode 100644 src/renderer/store/sleep-timer.store.ts mode change 100644 => 100755 src/shared/components/icon/icon.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json old mode 100644 new mode 100755 index fef87af0b..fbf541aa6 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 0acad2671..0ff9f5cc7 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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 ( <> + diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx old mode 100644 new mode 100755 index 5cddae08f..1f2b0a7b4 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -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 = () => { + diff --git a/src/renderer/features/player/components/sleep-timer-button.tsx b/src/renderer/features/player/components/sleep-timer-button.tsx new file mode 100644 index 000000000..d1f344bdb --- /dev/null +++ b/src/renderer/features/player/components/sleep-timer-button.tsx @@ -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(0); + const [customMinutes, setCustomMinutes] = useState(20); + const [customSeconds, setCustomSeconds] = useState(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 ( + + + { + e.stopPropagation(); + setOpened((prev) => !prev); + }} + size="sm" + tooltip={{ + label: t('player.sleepTimer', { postProcess: 'titleCase' }), + openDelay: 0, + }} + variant="subtle" + /> + + + + + {t('player.sleepTimer', { postProcess: 'titleCase' })} + + + {active && ( + + {mode === 'endOfSong' ? ( + + {t('player.sleepTimer_endOfSong', { + postProcess: 'sentenceCase', + })} + + ) : ( + + {formatRemaining(remaining)} + + )} + + + )} + + {PRESET_OPTIONS.map((option, index) => ( + + ))} + + {!showCustom ? ( + + ) : ( + + + setCustomHours(Number(val) || 0)} + placeholder="hr" + size="xs" + value={customHours} + /> + : + setCustomMinutes(Number(val) || 0)} + placeholder="min" + size="xs" + value={customMinutes} + /> + : + setCustomSeconds(Number(val) || 0)} + placeholder="sec" + size="xs" + value={customSeconds} + /> + + + + + + + )} + + + + ); +}; diff --git a/src/renderer/store/sleep-timer.store.ts b/src/renderer/store/sleep-timer.store.ts new file mode 100644 index 000000000..1f88798b8 --- /dev/null +++ b/src/renderer/store/sleep-timer.store.ts @@ -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()( + (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, + })), + ); diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx old mode 100644 new mode 100755 index 38415a192..f6727db3e --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -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,