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 { usePlayerShuffle, 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 { Divider } from '/@/shared/components/divider/divider'; import { Flex } from '/@/shared/components/flex/flex'; import { Grid } from '/@/shared/components/grid/grid'; 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 { 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 }, { minutes: 30, mode: 'timed' as const }, { minutes: 45, mode: 'timed' as const }, { minutes: 60, mode: 'timed' as const }, { minutes: 120, mode: 'timed' as const }, { minutes: 180, mode: 'timed' as const }, { minutes: 240, 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, 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; } // Cancel and pause on song change in end-of-song mode if (mode === 'endOfSong') { cancelTimer(); mediaPauseRef.current(); return; } // Cancel and pause song change in end-of-album mode if (mode === 'endOfAlbum') { evaluateEndOfAlbum(); } }, [active, mode, cancelTimer, evaluateEndOfAlbum, 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: set the pauseOnNextSongEnd flag so that // 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) { return; } if (mode === 'endOfSong') { usePlayerStoreBase.getState().setPauseOnNextSongEnd(true); return () => { usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); }; } if (mode === 'endOfAlbum') { evaluateEndOfAlbum(); return () => { usePlayerStoreBase.getState().setPauseOnNextSongEnd(false); }; } return undefined; }, [active, mode, evaluateEndOfAlbum]); }; 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, 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(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 if (option.mode === 'endOfAlbum') { startEndOfAlbumTimer(); } else { startTimedTimer(option.minutes * 60); } setShowCustom(false); setOpened(false); }, [startEndOfAlbumTimer, 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'); } if (option.mode === 'endOfAlbum') { return t('player.sleepTimer_endOfAlbum'); } if (option.minutes >= 60) { return t('player.sleepTimer_hours', { count: option.minutes / 60, }); } return t('player.sleepTimer_minutes', { count: option.minutes, }); }; return ( { e.stopPropagation(); setOpened((prev) => !prev); }} size="sm" tooltip={{ label: t('player.sleepTimer'), openDelay: 0, }} variant="subtle" /> {t('player.sleepTimer')} {active && ( {mode === 'endOfSong' ? ( {t('player.sleepTimer_endOfSong')} ) : mode === 'endOfAlbum' ? ( {t('player.sleepTimer_endOfAlbum')} ) : ( {formatRemaining(remaining)} )} )} {PRESET_OPTIONS.filter( (option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum', ).map((option) => { const disabled = option.mode === 'endOfAlbum' && isTrackShuffle; return ( ); })} {PRESET_OPTIONS.filter((option) => option.mode === 'timed').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} /> )} ); };