mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 22:32:17 +02:00
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:
@@ -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" />
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user