mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-10 12:39:58 +02:00
deb69ef8ea
* Add logic for stopping playback at end of current album, unless in shuffle mode.
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
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<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 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 (
|
|
<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'),
|
|
openDelay: 0,
|
|
}}
|
|
variant="subtle"
|
|
/>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<Stack gap="xs" p="xs">
|
|
<Text fw="600" pb="md" size="sm" ta="center">
|
|
{t('player.sleepTimer')}
|
|
</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')}
|
|
</Text>
|
|
) : mode === 'endOfAlbum' ? (
|
|
<Text c="primary" size="sm">
|
|
{t('player.sleepTimer_endOfAlbum')}
|
|
</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')}
|
|
</Button>
|
|
</Flex>
|
|
)}
|
|
|
|
{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={option.mode}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handlePreset(option);
|
|
}}
|
|
size="xs"
|
|
variant="outline"
|
|
>
|
|
{getPresetLabel(option)}
|
|
</Button>
|
|
);
|
|
})}
|
|
|
|
<Divider my="md" />
|
|
|
|
<Grid gap="xs">
|
|
{PRESET_OPTIONS.filter((option) => option.mode === 'timed').map(
|
|
(option, index) => (
|
|
<Grid.Col key={index} span={4}>
|
|
<Button
|
|
fullWidth
|
|
justify="flex-start"
|
|
key={index}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handlePreset(option);
|
|
}}
|
|
size="xs"
|
|
variant="outline"
|
|
>
|
|
{getPresetLabel(option)}
|
|
</Button>
|
|
</Grid.Col>
|
|
),
|
|
)}
|
|
</Grid>
|
|
|
|
<Divider my="md" />
|
|
|
|
{!showCustom ? (
|
|
<Button
|
|
fullWidth
|
|
justify="flex-start"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowCustom(true);
|
|
}}
|
|
size="xs"
|
|
ta="center"
|
|
variant="outline"
|
|
>
|
|
{t('player.sleepTimer_custom')}
|
|
</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')}
|
|
</Button>
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowCustom(false);
|
|
}}
|
|
size="xs"
|
|
variant="default"
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
};
|