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,