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
+27 -4
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')}>
<>
<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>
+19 -2
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')}>
<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,
);
}
+17 -2
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>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="player-btn player-btn-primary"
onClick={togglePlay}
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) {
+81
View File
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const LONG_PRESS_MS = 550;
const MOVE_CANCEL_PX = 10;
/**
* Long-press on play/pause opens the delay modal; short click toggles playback.
*/
export function usePlaybackDelayPress(togglePlay: () => void) {
const [delayModalOpen, setDelayModalOpen] = useState(false);
const ignoreNextClickRef = useRef(false);
const timerRef = useRef<number | null>(null);
const startRef = useRef({ x: 0, y: 0 });
// Long-press sets ignoreNextClickRef so the synthetic click after opening does not toggle play.
// Closing the modal from chips / Apply / overlay never hits this button's onClick, so clear the flag here.
useEffect(() => {
if (!delayModalOpen) ignoreNextClickRef.current = false;
}, [delayModalOpen]);
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
startRef.current = { x: e.clientX, y: e.clientY };
clearTimer();
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
ignoreNextClickRef.current = true;
setDelayModalOpen(true);
}, LONG_PRESS_MS) as unknown as number;
},
[clearTimer],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (timerRef.current == null) return;
if (
Math.hypot(e.clientX - startRef.current.x, e.clientY - startRef.current.y) > MOVE_CANCEL_PX
) {
clearTimer();
}
},
[clearTimer],
);
const endPointer = useCallback(() => {
clearTimer();
}, [clearTimer]);
const onClick = useCallback(
(e: React.MouseEvent) => {
if (ignoreNextClickRef.current) {
ignoreNextClickRef.current = false;
e.preventDefault();
e.stopPropagation();
return;
}
togglePlay();
},
[togglePlay],
);
const playPauseBind = {
onPointerDown,
onPointerMove,
onPointerUp: endPointer,
onPointerLeave: endPointer,
onPointerCancel: endPointer,
onClick,
};
return { delayModalOpen, setDelayModalOpen, playPauseBind };
}
+18
View File
@@ -1126,6 +1126,24 @@ export const deTranslation = {
prev: 'Vorheriger Titel',
play: 'Play',
pause: 'Pause',
delayModalTitle: 'Timer',
delayPauseSection: 'Pause nach',
delayStartSection: 'Start nach',
delaySchedulePause: 'Pause planen',
delayScheduleStart: 'Start planen',
delayCancelPause: 'Pause-Timer abbrechen',
delayCancelStart: 'Start-Timer abbrechen',
delayInactivePause: 'Nur während der Wiedergabe verfügbar.',
delayInactiveStart: 'Nur in Pause mit geladenem Titel oder Radio.',
delayCustomMinutes: 'Eigene Dauer (Minuten)',
delayCustomPlaceholder: 'z. B. 2,5',
closeDelayModal: 'Schließen',
delayIn: 'in',
delayFmtSec: '{{n}} s',
delayFmtMin: '{{n}} Min',
delayFmtHr: '{{n}} Std',
delayCancel: 'Abbrechen',
delayApply: 'Übernehmen',
next: 'Nächster Titel',
repeat: 'Wiederholen',
repeatOff: 'Aus',
+18
View File
@@ -1128,6 +1128,24 @@ export const enTranslation = {
prev: 'Previous Track',
play: 'Play',
pause: 'Pause',
delayModalTitle: 'Timer',
delayPauseSection: 'Pause after',
delayStartSection: 'Start after',
delaySchedulePause: 'Schedule pause',
delayScheduleStart: 'Schedule start',
delayCancelPause: 'Cancel pause timer',
delayCancelStart: 'Cancel start timer',
delayInactivePause: 'Available only while something is playing.',
delayInactiveStart: 'Available when paused with a track or radio loaded.',
delayCustomMinutes: 'Custom delay (minutes)',
delayCustomPlaceholder: 'e.g. 2.5',
closeDelayModal: 'Close',
delayIn: 'in',
delayFmtSec: '{{n}}s',
delayFmtMin: '{{n}} min',
delayFmtHr: '{{n}} h',
delayCancel: 'Cancel',
delayApply: 'Apply',
next: 'Next Track',
repeat: 'Repeat',
repeatOff: 'Off',
+18
View File
@@ -1119,6 +1119,24 @@ export const esTranslation = {
prev: 'Pista Anterior',
play: 'Reproducir',
pause: 'Pausa',
delayModalTitle: 'Temporizador',
delayPauseSection: 'Pausar después de',
delayStartSection: 'Iniciar después de',
delaySchedulePause: 'Programar pausa',
delayScheduleStart: 'Programar inicio',
delayCancelPause: 'Cancelar temporizador de pausa',
delayCancelStart: 'Cancelar temporizador de inicio',
delayInactivePause: 'Solo disponible durante la reproducción.',
delayInactiveStart: 'Solo en pausa con pista o radio cargada.',
delayCustomMinutes: 'Retraso personalizado (minutos)',
delayCustomPlaceholder: 'p. ej. 2,5',
closeDelayModal: 'Cerrar',
delayIn: 'en',
delayFmtSec: '{{n}} s',
delayFmtMin: '{{n}} min',
delayFmtHr: '{{n}} h',
delayCancel: 'Cancelar',
delayApply: 'Aplicar',
next: 'Pista Siguiente',
repeat: 'Repetir',
repeatOff: 'Apagado',
+18
View File
@@ -1114,6 +1114,24 @@ export const frTranslation = {
prev: 'Piste précédente',
play: 'Lecture',
pause: 'Pause',
delayModalTitle: 'Minuteur',
delayPauseSection: 'Pause après',
delayStartSection: 'Démarrage après',
delaySchedulePause: 'Programmer la pause',
delayScheduleStart: 'Programmer le démarrage',
delayCancelPause: 'Annuler le minuteur de pause',
delayCancelStart: 'Annuler le minuteur de démarrage',
delayInactivePause: 'Disponible uniquement pendant la lecture.',
delayInactiveStart: 'Disponible en pause avec une piste ou une radio chargée.',
delayCustomMinutes: 'Délai personnalisé (minutes)',
delayCustomPlaceholder: 'ex. 2,5',
closeDelayModal: 'Fermer',
delayIn: 'dans',
delayFmtSec: '{{n}} s',
delayFmtMin: '{{n}} min',
delayFmtHr: '{{n}} h',
delayCancel: 'Annuler',
delayApply: 'Appliquer',
next: 'Piste suivante',
repeat: 'Répéter',
repeatOff: 'Désactivé',
+18
View File
@@ -1113,6 +1113,24 @@ export const nbTranslation = {
prev: 'Forrige spor',
play: 'Spill av',
pause: 'Pause',
delayModalTitle: 'Tidsur',
delayPauseSection: 'Pause etter',
delayStartSection: 'Start etter',
delaySchedulePause: 'Planlegg pause',
delayScheduleStart: 'Planlegg start',
delayCancelPause: 'Avbryt pause-timer',
delayCancelStart: 'Avbryt start-timer',
delayInactivePause: 'Kun tilgjengelig under avspilling.',
delayInactiveStart: 'Kun i pause med spor eller radio lastet.',
delayCustomMinutes: 'Egen forsinkelse (minutter)',
delayCustomPlaceholder: 'f.eks. 2,5',
closeDelayModal: 'Lukk',
delayIn: 'om',
delayFmtSec: '{{n}} s',
delayFmtMin: '{{n}} min',
delayFmtHr: '{{n}} t',
delayCancel: 'Avbryt',
delayApply: 'Bruk',
next: 'Neste spor',
repeat: 'Gjenta',
repeatOff: 'Av',
+18
View File
@@ -1113,6 +1113,24 @@ export const nlTranslation = {
prev: 'Vorig nummer',
play: 'Afspelen',
pause: 'Pauzeren',
delayModalTitle: 'Timer',
delayPauseSection: 'Pauzeren na',
delayStartSection: 'Starten na',
delaySchedulePause: 'Pauze plannen',
delayScheduleStart: 'Start plannen',
delayCancelPause: 'Pauze-timer annuleren',
delayCancelStart: 'Start-timer annuleren',
delayInactivePause: 'Alleen beschikbaar tijdens afspelen.',
delayInactiveStart: 'Alleen in pauze met een geladen nummer of radio.',
delayCustomMinutes: 'Aangepaste vertraging (minuten)',
delayCustomPlaceholder: 'bijv. 2,5',
closeDelayModal: 'Sluiten',
delayIn: 'over',
delayFmtSec: '{{n}} s',
delayFmtMin: '{{n}} min',
delayFmtHr: '{{n}} u',
delayCancel: 'Annuleren',
delayApply: 'Toepassen',
next: 'Volgend nummer',
repeat: 'Herhalen',
repeatOff: 'Uit',
+18
View File
@@ -1191,6 +1191,24 @@ export const ruTranslation = {
prev: 'Предыдущий трек',
play: 'Играть',
pause: 'Пауза',
delayModalTitle: 'Таймер',
delayPauseSection: 'Пауза через',
delayStartSection: 'Старт через',
delaySchedulePause: 'Запланировать паузу',
delayScheduleStart: 'Запланировать старт',
delayCancelPause: 'Отменить таймер паузы',
delayCancelStart: 'Отменить таймер старта',
delayInactivePause: 'Доступно только во время воспроизведения.',
delayInactiveStart: 'Доступно на паузе при загруженном треке или радио.',
delayCustomMinutes: 'Своя задержка (минуты)',
delayCustomPlaceholder: 'напр. 2.5',
closeDelayModal: 'Закрыть',
delayIn: 'через',
delayFmtSec: '{{n}} с',
delayFmtMin: '{{n}} мин',
delayFmtHr: '{{n}} ч',
delayCancel: 'Отмена',
delayApply: 'Применить',
next: 'Следующий трек',
repeat: 'Повтор',
repeatOff: 'Выкл.',
+18
View File
@@ -1108,6 +1108,24 @@ export const zhTranslation = {
prev: '上一首',
play: '播放',
pause: '暂停',
delayModalTitle: '定时',
delayPauseSection: '多久后暂停',
delayStartSection: '多久后开始',
delaySchedulePause: '安排暂停',
delayScheduleStart: '安排开始',
delayCancelPause: '取消暂停定时',
delayCancelStart: '取消开始定时',
delayInactivePause: '仅在正在播放时可用。',
delayInactiveStart: '仅在暂停且已加载曲目或电台时可用。',
delayCustomMinutes: '自定义延迟(分钟)',
delayCustomPlaceholder: '例如 2.5',
closeDelayModal: '关闭',
delayIn: '还有',
delayFmtSec: '{{n}} 秒',
delayFmtMin: '{{n}} 分钟',
delayFmtHr: '{{n}} 小时',
delayCancel: '取消',
delayApply: '应用',
next: '下一首',
repeat: '重复',
repeatOff: '关闭',
+84 -1
View File
@@ -175,6 +175,14 @@ interface PlayerState {
resume: () => void;
stop: () => void;
togglePlay: () => void;
/** Wall-clock ms when auto-pause fires, or null. */
scheduledPauseAtMs: number | null;
/** Wall-clock ms when auto-resume fires, or null. */
scheduledResumeAtMs: number | null;
schedulePauseIn: (seconds: number) => void;
scheduleResumeIn: (seconds: number) => void;
clearScheduledPause: () => void;
clearScheduledResume: () => void;
next: (manual?: boolean) => void;
previous: () => void;
seek: (progress: number) => void;
@@ -264,6 +272,29 @@ const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
/** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */
let scheduledPauseTimer: number | null = null;
let scheduledResumeTimer: number | null = null;
function clearScheduledPauseTimers() {
if (scheduledPauseTimer != null) {
window.clearTimeout(scheduledPauseTimer);
scheduledPauseTimer = null;
}
}
function clearScheduledResumeTimers() {
if (scheduledResumeTimer != null) {
window.clearTimeout(scheduledResumeTimer);
scheduledResumeTimer = null;
}
}
function clearAllPlaybackScheduleTimers() {
clearScheduledPauseTimers();
clearScheduledResumeTimers();
}
function setSeekTarget(seconds: number) {
seekTarget = seconds;
seekTargetSetAt = Date.now();
@@ -1019,6 +1050,8 @@ export const usePlayerStore = create<PlayerState>()(
}),
isQueueVisible: true,
isFullscreenOpen: false,
scheduledPauseAtMs: null,
scheduledResumeAtMs: null,
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
@@ -1093,6 +1126,7 @@ export const usePlayerStore = create<PlayerState>()(
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
@@ -1112,6 +1146,8 @@ export const usePlayerStore = create<PlayerState>()(
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
scheduledPauseAtMs: null,
scheduledResumeAtMs: null,
});
},
@@ -1119,6 +1155,8 @@ export const usePlayerStore = create<PlayerState>()(
playRadio: async (station) => {
const { volume } = get();
++playGeneration;
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
isAudioPaused = false;
clearRadioReconnectTimer();
radioReconnectCount = 0;
@@ -1163,6 +1201,9 @@ export const usePlayerStore = create<PlayerState>()(
return;
}
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
@@ -1315,13 +1356,14 @@ export const usePlayerStore = create<PlayerState>()(
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
radioAudio.pause();
} else {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
}
set({ isPlaying: false });
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledResumeAtMs: null });
},
resetAudioPause: () => {
@@ -1329,6 +1371,8 @@ export const usePlayerStore = create<PlayerState>()(
},
resume: () => {
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
if (get().currentRadio) {
radioAudio.play().catch(console.error);
set({ isPlaying: true });
@@ -1421,6 +1465,45 @@ export const usePlayerStore = create<PlayerState>()(
}
},
clearScheduledPause: () => {
clearScheduledPauseTimers();
set({ scheduledPauseAtMs: null });
},
clearScheduledResume: () => {
clearScheduledResumeTimers();
set({ scheduledResumeAtMs: null });
},
schedulePauseIn: (seconds) => {
const s = get();
if (!s.isPlaying) return;
clearScheduledPauseTimers();
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
const at = Date.now() + delayMs;
set({ scheduledPauseAtMs: at });
scheduledPauseTimer = window.setTimeout(() => {
scheduledPauseTimer = null;
set({ scheduledPauseAtMs: null });
get().pause();
}, delayMs) as unknown as number;
},
scheduleResumeIn: (seconds) => {
const s = get();
if (s.isPlaying) return;
if (!s.currentTrack && !s.currentRadio) return;
clearScheduledResumeTimers();
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
const at = Date.now() + delayMs;
set({ scheduledResumeAtMs: at });
scheduledResumeTimer = window.setTimeout(() => {
scheduledResumeTimer = null;
set({ scheduledResumeAtMs: null });
get().resume();
}, delayMs) as unknown as number;
},
togglePlay: () => {
if (togglePlayLock) return;
togglePlayLock = true;
+207
View File
@@ -2048,6 +2048,213 @@
font-family: var(--font-display);
}
/* ─ Playback delay (sleep / delayed start) modal ─ */
.playback-delay-modal {
max-height: min(85vh, 560px);
padding: var(--space-6);
}
.playback-delay-modal__title {
margin: 0 0 var(--space-4);
padding-right: 2rem;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
}
.playback-delay-modal-overlay--anchored {
padding: 0;
}
.playback-transport-play-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.playback-schedule-badge--floating {
display: flex;
align-items: center;
justify-content: center;
transform-origin: top right;
min-width: 1.55rem;
height: 1.45rem;
padding: 0 5px;
border-radius: 999px;
font-size: 0.6rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
color: var(--text-primary);
background: color-mix(in srgb, var(--ctp-surface0) 88%, var(--text-primary) 4%);
border: 1px solid var(--border-subtle, var(--border));
box-shadow: 0 1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
pointer-events: none;
white-space: nowrap;
}
.playback-schedule-badge--floating.playback-schedule-badge--fs {
font-size: 0.64rem;
min-width: 1.65rem;
height: 1.5rem;
padding: 0 6px;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.playback-delay-section__head--tight {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
min-height: 0;
}
.playback-delay-inline-cancel {
flex-shrink: 0;
padding: 4px 10px;
font-size: 0.8rem;
}
.playback-delay-chips--compact {
margin-bottom: var(--space-4);
gap: 5px;
}
.playback-delay-custom--inline {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
margin-top: 0;
}
.playback-delay-custom--inline .playback-delay-custom__input {
flex: 1 1 100px;
min-width: 0;
}
.playback-delay-idle {
margin-bottom: var(--space-2);
}
.playback-delay-idle .playback-delay-muted:first-child {
margin-top: 0;
}
.playback-delay-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
background: color-mix(in srgb, var(--ctp-base) 40%, transparent);
}
.playback-delay-section--disabled {
opacity: 0.55;
pointer-events: none;
}
.playback-delay-section__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.playback-delay-section__title {
font-weight: 600;
font-size: 0.95rem;
}
.playback-delay-section__countdown {
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
color: var(--accent);
white-space: nowrap;
}
.playback-delay-muted {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
}
.playback-delay-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--space-3);
}
.playback-delay-chip {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 0.8rem;
background: var(--ctp-surface1);
color: var(--text-primary);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.playback-delay-chip:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--ctp-surface1));
}
.playback-delay-chip--on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--ctp-surface1));
color: var(--accent);
}
.playback-delay-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.playback-delay-custom__label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-muted);
}
.playback-delay-custom__row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.playback-delay-custom__input {
flex: 1 1 120px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 0.9rem;
background: var(--ctp-base);
color: var(--text-primary);
}
.playback-delay-custom__input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
/* ─ Playlist edit modal ─ */
.playlist-edit-modal {
max-width: 560px;
+13
View File
@@ -0,0 +1,13 @@
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
if (deadlineMs == null) return '';
const sec = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1000));
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}:${rm.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}