From 624ce56faf47fdda31e338ca215e217a86c2ca89 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:32:44 +0300 Subject: [PATCH] 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. --- src/components/FullscreenPlayer.tsx | 35 ++- src/components/MobilePlayerView.tsx | 25 ++- src/components/PlaybackDelayModal.tsx | 258 +++++++++++++++++++++++ src/components/PlaybackScheduleBadge.tsx | 81 +++++++ src/components/PlayerBar.tsx | 33 ++- src/hooks/usePlaybackDelayPress.ts | 81 +++++++ src/locales/de.ts | 18 ++ src/locales/en.ts | 18 ++ src/locales/es.ts | 18 ++ src/locales/fr.ts | 18 ++ src/locales/nb.ts | 18 ++ src/locales/nl.ts | 18 ++ src/locales/ru.ts | 18 ++ src/locales/zh.ts | 18 ++ src/store/playerStore.ts | 85 +++++++- src/styles/components.css | 207 ++++++++++++++++++ src/utils/playbackScheduleFormat.ts | 13 ++ 17 files changed, 942 insertions(+), 20 deletions(-) create mode 100644 src/components/PlaybackDelayModal.tsx create mode 100644 src/components/PlaybackScheduleBadge.tsx create mode 100644 src/hooks/usePlaybackDelayPress.ts create mode 100644 src/utils/playbackScheduleFormat.ts diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 96bee923..ef016995 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -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; +}) { const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const togglePlay = usePlayerStore(s => s.togglePlay); + const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); + const playSlotRef = useRef(null); return ( - + <> + + + + + 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(null); + const fsControlsRef = useRef(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 */} -
+
- + diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index ade6f7b7..64ce1ee0 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -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(null); + const playSlotRef = useRef(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() {
{/* Transport Controls */} -
+
- + + + + @@ -406,6 +421,8 @@ export default function MobilePlayerView() { {/* Lyrics Drawer */} {showLyrics && setShowLyrics(false)} currentTrack={currentTrack} />} + + setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
); } diff --git a/src/components/PlaybackDelayModal.tsx b/src/components/PlaybackDelayModal.tsx new file mode 100644 index 00000000..93848905 --- /dev/null +++ b/src/components/PlaybackDelayModal.tsx @@ -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; +} + +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( +
+
e.stopPropagation()} + style={panelStyle} + > + +

+ {heading} +

+ + {canPauseLater && ( + <> + {scheduledPauseAtMs != null && ( +
+ + {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, nowTick)} + + +
+ )} +
+ {PRESET_SECONDS.map(sec => ( + + ))} +
+ + )} + + {canStartLater && ( + <> + {scheduledResumeAtMs != null && ( +
+ + {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledResumeAtMs, nowTick)} + + +
+ )} +
+ {PRESET_SECONDS.map(sec => ( + + ))} +
+ + )} + + {!canPauseLater && !canStartLater && ( +
+

{t('player.delayInactivePause')}

+

{t('player.delayInactiveStart')}

+
+ )} + + {(canPauseLater || canStartLater) && ( +
+ setCustomMinutes(e.target.value)} + aria-label={t('player.delayCustomMinutes')} + /> + +
+ )} +
+
, + document.body, + ); +} diff --git a/src/components/PlaybackScheduleBadge.tsx b/src/components/PlaybackScheduleBadge.tsx new file mode 100644 index 00000000..6f088af7 --- /dev/null +++ b/src/components/PlaybackScheduleBadge.tsx @@ -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; + /** 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({ 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( + + {text} + , + document.body, + ); +} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index fb2df1f8..eb746a85 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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(null); + const playSlotRef = useRef(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 = ( + <>
{/* Transport Controls */} -
+
- + + + + @@ -448,6 +461,8 @@ export default function PlayerBar() { )}
+ setDelayModalOpen(false)} anchorRef={transportAnchorRef} /> + ); if (floatingPlayerBar) { diff --git a/src/hooks/usePlaybackDelayPress.ts b/src/hooks/usePlaybackDelayPress.ts new file mode 100644 index 00000000..5eb46534 --- /dev/null +++ b/src/hooks/usePlaybackDelayPress.ts @@ -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(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 }; +} diff --git a/src/locales/de.ts b/src/locales/de.ts index a8b61c08..f1fa94c7 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 1b5b68e1..974ada4a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index d97b63ee..732cce9f 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 14a992ee..9bc1da79 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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é', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 8420dc11..a0be759f 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index bec45023..099cc11c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index fb4fe425..0c7a0e0e 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Выкл.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 5bcdaf8c..775e522b 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '关闭', diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 27757c63..d887fca5 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( }), 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()( // ── stop ──────────────────────────────────────────────────────────────── stop: () => { + clearAllPlaybackScheduleTimers(); if (get().currentRadio) { clearRadioReconnectTimer(); radioStopping = true; @@ -1112,6 +1146,8 @@ export const usePlayerStore = create()( currentRadio: null, currentPlaybackSource: null, enginePreloadedTrackId: null, + scheduledPauseAtMs: null, + scheduledResumeAtMs: null, }); }, @@ -1119,6 +1155,8 @@ export const usePlayerStore = create()( 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()( 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()( // ── 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()( }, 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()( } }, + 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; diff --git a/src/styles/components.css b/src/styles/components.css index 9eee6b10..e5be6aab 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/utils/playbackScheduleFormat.ts b/src/utils/playbackScheduleFormat.ts new file mode 100644 index 00000000..c4c024f8 --- /dev/null +++ b/src/utils/playbackScheduleFormat.ts @@ -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')}`; +}