feat(player): sleep timer and delayed start via long-press on play/pause (#270)

Add scheduled pause and resume timers in the player store, cleared on stop
and track changes. Long-press opens a compact preset modal anchored above
the transport row; one-tap presets plus custom minutes. Portaled countdown
badge on the play button; clear the long-press click guard when the modal
closes so the first play/pause click works after scheduling.
This commit is contained in:
cucadmuh
2026-04-23 00:32:44 +03:00
committed by GitHub
parent c5dfabf739
commit 624ce56faf
17 changed files with 942 additions and 20 deletions
+29 -6
View File
@@ -14,6 +14,9 @@ import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -582,14 +585,33 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, tr
});
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
const FsPlayBtn = memo(function FsPlayBtn() {
const FsPlayBtn = memo(function FsPlayBtn({
controlsAnchorRef,
}: {
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const playSlotRef = useRef<HTMLSpanElement>(null);
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
<>
<span ref={playSlotRef} className="playback-transport-play-wrap">
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
<button
type="button"
className="fs-btn fs-btn-play"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
</span>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
</>
);
});
@@ -716,6 +738,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
const fsControlsRef = useRef<HTMLDivElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity
const [isIdle, setIsIdle] = useState(false);
@@ -822,14 +845,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
)}
{/* Controls */}
<div className="fs-controls">
<div className="fs-controls" ref={fsControlsRef}>
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
+21 -4
View File
@@ -9,6 +9,9 @@ import { usePlayerStore, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
// ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
@@ -162,6 +165,9 @@ export default function MobilePlayerView() {
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const seek = usePlayerStore(s => s.seek);
@@ -362,7 +368,7 @@ export default function MobilePlayerView() {
</div>
{/* Transport Controls */}
<div className="mp-controls">
<div className="mp-controls" ref={transportAnchorRef}>
<button
className="mp-ctrl-btn mp-ctrl-sm"
onClick={() => shuffleQueue()}
@@ -373,9 +379,18 @@ export default function MobilePlayerView() {
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="mp-ctrl-btn mp-ctrl-play"
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
</span>
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
@@ -406,6 +421,8 @@ export default function MobilePlayerView() {
{/* Lyrics Drawer */}
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import type { TFunction } from 'i18next';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
/** One tap = schedule; custom minutes still covers any duration. */
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
function formatPresetLabel(seconds: number, t: TFunction): string {
if (seconds < 60) return t('player.delayFmtSec', { n: seconds });
if (seconds < 3600) return t('player.delayFmtMin', { n: seconds / 60 });
return t('player.delayFmtHr', { n: seconds / 3600 });
}
function computeAnchoredPanelStyle(anchorEl: HTMLElement): React.CSSProperties {
const ar = anchorEl.getBoundingClientRect();
const mw = Math.min(360, Math.max(200, window.innerWidth - 32));
let left = ar.left + ar.width / 2 - mw / 2;
const pad = 12;
left = Math.max(pad, Math.min(left, window.innerWidth - mw - pad));
const gap = 10;
return {
position: 'fixed',
left,
bottom: window.innerHeight - ar.top + gap,
width: mw,
maxWidth: 360,
margin: 0,
maxHeight: 'min(72vh, calc(100vh - 24px))',
overflowY: 'auto',
};
}
export interface PlaybackDelayModalProps {
open: boolean;
onClose: () => void;
/** When set, panel is fixed just above this element (transport strip). */
anchorRef?: React.RefObject<HTMLElement | null>;
}
export default function PlaybackDelayModal({ open, onClose, anchorRef }: PlaybackDelayModalProps) {
const { t } = useTranslation();
const {
isPlaying,
currentTrack,
currentRadio,
scheduledPauseAtMs,
scheduledResumeAtMs,
schedulePauseIn,
scheduleResumeIn,
clearScheduledPause,
clearScheduledResume,
} = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
currentTrack: s.currentTrack,
currentRadio: s.currentRadio,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
schedulePauseIn: s.schedulePauseIn,
scheduleResumeIn: s.scheduleResumeIn,
clearScheduledPause: s.clearScheduledPause,
clearScheduledResume: s.clearScheduledResume,
})),
);
const [nowTick, setNowTick] = useState(() => Date.now());
const [posTick, setPosTick] = useState(0);
const [customMinutes, setCustomMinutes] = useState('');
useEffect(() => {
if (!open) return;
setCustomMinutes('');
}, [open]);
useEffect(() => {
if (!open) return;
if (scheduledPauseAtMs == null && scheduledResumeAtMs == null) return;
const id = window.setInterval(() => setNowTick(Date.now()), 500);
return () => window.clearInterval(id);
}, [open, scheduledPauseAtMs, scheduledResumeAtMs]);
useEffect(() => {
if (!open || !anchorRef) return;
const bump = () => setPosTick(x => x + 1);
window.addEventListener('resize', bump);
window.addEventListener('scroll', bump, true);
return () => {
window.removeEventListener('resize', bump);
window.removeEventListener('scroll', bump, true);
};
}, [open, anchorRef]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const canPauseLater = isPlaying && (!!currentTrack || !!currentRadio);
const canStartLater = !isPlaying && (!!currentTrack || !!currentRadio);
const customSeconds = useMemo(() => {
const n = parseFloat(customMinutes.replace(',', '.'));
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 60);
}, [customMinutes]);
const applyPause = (sec: number) => {
schedulePauseIn(sec);
onClose();
};
const applyStart = (sec: number) => {
scheduleResumeIn(sec);
onClose();
};
const useAnchor = !!anchorRef;
const anchorEl = anchorRef?.current ?? null;
void posTick;
const anchoredPanelStyle =
open && useAnchor && anchorEl ? computeAnchoredPanelStyle(anchorEl) : undefined;
const heading =
canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle');
if (!open) return null;
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle;
return createPortal(
<div
className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="playback-delay-modal-title"
style={
useAnchor
? { alignItems: 'stretch', justifyContent: 'flex-start', padding: 0 }
: { alignItems: 'center', paddingTop: 0 }
}
>
<div
className="modal-content playback-delay-modal"
onClick={e => e.stopPropagation()}
style={panelStyle}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}>
<X size={18} />
</button>
<h3 id="playback-delay-modal-title" className="playback-delay-modal__title">
{heading}
</h3>
{canPauseLater && (
<>
{scheduledPauseAtMs != null && (
<div className="playback-delay-section__head playback-delay-section__head--tight">
<span className="playback-delay-section__countdown">
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, nowTick)}
</span>
<button
type="button"
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
aria-label={t('player.delayCancelPause')}
onClick={() => clearScheduledPause()}
>
{t('player.delayCancel')}
</button>
</div>
)}
<div className="playback-delay-chips playback-delay-chips--compact">
{PRESET_SECONDS.map(sec => (
<button key={`p-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyPause(sec)}>
{formatPresetLabel(sec, t)}
</button>
))}
</div>
</>
)}
{canStartLater && (
<>
{scheduledResumeAtMs != null && (
<div className="playback-delay-section__head playback-delay-section__head--tight">
<span className="playback-delay-section__countdown">
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledResumeAtMs, nowTick)}
</span>
<button
type="button"
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
aria-label={t('player.delayCancelStart')}
onClick={() => clearScheduledResume()}
>
{t('player.delayCancel')}
</button>
</div>
)}
<div className="playback-delay-chips playback-delay-chips--compact">
{PRESET_SECONDS.map(sec => (
<button key={`s-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyStart(sec)}>
{formatPresetLabel(sec, t)}
</button>
))}
</div>
</>
)}
{!canPauseLater && !canStartLater && (
<div className="playback-delay-idle">
<p className="playback-delay-muted">{t('player.delayInactivePause')}</p>
<p className="playback-delay-muted">{t('player.delayInactiveStart')}</p>
</div>
)}
{(canPauseLater || canStartLater) && (
<div className="playback-delay-custom playback-delay-custom--inline">
<input
id="playback-delay-custom-min"
type="text"
inputMode="decimal"
className="playback-delay-custom__input"
placeholder={t('player.delayCustomPlaceholder')}
value={customMinutes}
onChange={e => setCustomMinutes(e.target.value)}
aria-label={t('player.delayCustomMinutes')}
/>
<button
type="button"
className="btn btn-primary"
disabled={customSeconds == null}
aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
onClick={() => {
if (customSeconds == null) return;
if (canPauseLater) applyPause(customSeconds);
else applyStart(customSeconds);
}}
>
{t('player.delayApply')}
</button>
</div>
)}
</div>
</div>,
document.body,
);
}
+81
View File
@@ -0,0 +1,81 @@
import React, { useEffect, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
export interface PlaybackScheduleBadgeProps {
/** Wrap around play/pause — used to position the floating pill (viewport-fixed, avoids player-bar clip). */
layoutAnchorRef: React.RefObject<HTMLElement | null>;
/** Extra classes on the portaled pill (e.g. fullscreen sizing). */
className?: string;
}
/**
* Small pill at the top-right of play/pause (overlapping) when a timer is armed.
* Portaled to `document.body` so it is not clipped by `contain: paint` on the player bar.
*/
export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
const { t } = useTranslation();
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
})),
);
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
const [nowMs, setNowMs] = useState(() => Date.now());
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
useEffect(() => {
if (deadlineMs == null) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500);
return () => window.clearInterval(id);
}, [deadlineMs]);
useLayoutEffect(() => {
if (deadlineMs == null) return;
const el = layoutAnchorRef.current;
if (!el) return;
const sync = () => {
const r = el.getBoundingClientRect();
setPanelStyle({
position: 'fixed',
left: r.right,
top: r.top,
transform: 'translate(-88%, -36%)',
zIndex: 9998,
visibility: 'visible',
});
};
sync();
window.addEventListener('resize', sync);
window.addEventListener('scroll', sync, true);
const iv = window.setInterval(sync, 400);
return () => {
window.removeEventListener('resize', sync);
window.removeEventListener('scroll', sync, true);
window.clearInterval(iv);
};
}, [deadlineMs, layoutAnchorRef]);
if (deadlineMs == null) return null;
const text = formatPlaybackScheduleRemaining(deadlineMs, nowMs);
const label =
isPlaying && scheduledPauseAtMs != null
? `${t('player.delayPauseSection')}: ${t('player.delayIn')} ${text}`
: `${t('player.delayStartSection')}: ${t('player.delayIn')} ${text}`;
const pillClass = ['playback-schedule-badge', 'playback-schedule-badge--floating', className].filter(Boolean).join(' ');
return createPortal(
<span className={pillClass} style={panelStyle} aria-live="polite" title={label}>
{text}
</span>,
document.body,
);
}
+24 -9
View File
@@ -21,6 +21,9 @@ import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -136,6 +139,10 @@ export default function PlayerBar() {
};
}, [floatingPlayerBar]);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const isRadio = !!currentRadio;
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
@@ -185,6 +192,7 @@ export default function PlayerBar() {
};
const playerBarContent = (
<>
<footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
@@ -292,21 +300,26 @@ export default function PlayerBar() {
</div>
{/* Transport Controls */}
<div className="player-buttons">
<div className="player-buttons" ref={transportAnchorRef}>
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
className="player-btn player-btn-primary"
onClick={togglePlay}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="player-btn player-btn-primary"
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
</span>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
@@ -448,6 +461,8 @@ export default function PlayerBar() {
)}
</footer>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</>
);
if (floatingPlayerBar) {