From cd47a4b0fae8c087f4f6f01865d05d009ec977f9 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:37:17 +0300 Subject: [PATCH] fix(player): clamp custom delay input and align preview with armed timer (#967) * fix(player): clamp custom delay input and align preview with armed timer Reject absurd custom minute values that overflow setTimeout, share one delay helper between the modal preview and schedule actions, and refresh countdown ticks when a new deadline is armed. * docs: note PR #967 in changelog and settings credits --- CHANGELOG.md | 8 +++ src/components/PlaybackDelayModal.tsx | 22 +++++-- src/components/PlaybackScheduleBadge.tsx | 5 ++ src/config/settingsCredits.ts | 1 + src/store/scheduleActions.ts | 9 +-- src/utils/format/playbackScheduleFormat.ts | 4 ++ .../playback/playbackScheduleDelay.test.ts | 65 +++++++++++++++++++ src/utils/playback/playbackScheduleDelay.ts | 42 ++++++++++++ 8 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 src/utils/playback/playbackScheduleDelay.test.ts create mode 100644 src/utils/playback/playbackScheduleDelay.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb800c8..ad935963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -474,6 +474,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Suggested Songs rows now render BPM (and other optional columns like genre, play count, last played) — the column switch was missing those cases. +### Player transport — custom delay input validation + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#967](https://github.com/Psychotoxical/psysonic/pull/967)** + +* Absurd custom minute values (e.g. eleven nines) no longer arm an immediate timer while the preview still shows a far-future start time — input is capped to the browser delay limit and Apply stays disabled when out of range. +* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses. + + ### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** diff --git a/src/components/PlaybackDelayModal.tsx b/src/components/PlaybackDelayModal.tsx index 57dfc4f9..d47b9e34 100644 --- a/src/components/PlaybackDelayModal.tsx +++ b/src/components/PlaybackDelayModal.tsx @@ -9,6 +9,12 @@ import { useShallow } from 'zustand/react/shallow'; import type { TFunction } from 'i18next'; import { formatPlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat'; import { formatClockTime } from '../utils/format/formatClockTime'; +import { + isValidPlaybackSchedulePreviewTimestamp, + parsePlaybackDelayCustomMinutes, + scheduleDelayMsFromSeconds, + scheduleSecondsFromCustomMinutes, +} from '../utils/playback/playbackScheduleDelay'; /** One tap = schedule; custom minutes still covers any duration. */ const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const; @@ -123,9 +129,9 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac 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); + const minutes = parsePlaybackDelayCustomMinutes(customMinutes); + if (minutes == null) return null; + return scheduleSecondsFromCustomMinutes(minutes); }, [customMinutes]); const applyPause = (sec: number) => { @@ -156,8 +162,14 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac // Priority: hovered chip → typed custom minutes → nothing. const clockFormat = useAuthStore(s => s.clockFormat); const previewSeconds = hoverSeconds ?? customSeconds; - const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null; - const previewClock = previewAtMs != null ? formatClockTime(previewAtMs, clockFormat, i18n.language) : null; + const previewAtMs = + previewSeconds != null + ? nowTick + scheduleDelayMsFromSeconds(previewSeconds) + : null; + const previewClock = + previewAtMs != null && isValidPlaybackSchedulePreviewTimestamp(previewAtMs) + ? formatClockTime(previewAtMs, clockFormat, i18n.language) + : null; if (!open) return null; diff --git a/src/components/PlaybackScheduleBadge.tsx b/src/components/PlaybackScheduleBadge.tsx index f36aecb4..f4bcae24 100644 --- a/src/components/PlaybackScheduleBadge.tsx +++ b/src/components/PlaybackScheduleBadge.tsx @@ -49,6 +49,11 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null); const windowHidden = useWindowVisibility(); + useEffect(() => { + if (deadlineMs == null) return; + setNowMs(Date.now()); + }, [deadlineMs]); + useEffect(() => { if (deadlineMs == null || windowHidden) return; const id = window.setInterval(() => { diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 2736b65c..5d9249c1 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -152,6 +152,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)', 'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)', 'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)', + 'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)', ], }, { diff --git a/src/store/scheduleActions.ts b/src/store/scheduleActions.ts index 0cf6638b..bf5a1a8e 100644 --- a/src/store/scheduleActions.ts +++ b/src/store/scheduleActions.ts @@ -1,3 +1,4 @@ +import { scheduleDeadlineMs, scheduleDelayMsFromSeconds } from '../utils/playback/playbackScheduleDelay'; import type { PlayerState } from './playerStoreTypes'; import { clearScheduledPauseTimers, @@ -36,9 +37,9 @@ export function createScheduleActions(set: SetState, get: GetState): Pick< schedulePauseIn: (seconds) => { const s = get(); if (!s.isPlaying) return; - const delayMs = Math.max(500, Math.round(Number(seconds) * 1000)); + const delayMs = scheduleDelayMsFromSeconds(seconds); const startedAt = Date.now(); - const at = startedAt + delayMs; + const at = scheduleDeadlineMs(startedAt, seconds); set({ scheduledPauseAtMs: at, scheduledPauseStartMs: startedAt }); schedulePauseTimer(delayMs, () => { set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null }); @@ -50,9 +51,9 @@ export function createScheduleActions(set: SetState, get: GetState): Pick< const s = get(); if (s.isPlaying) return; if (!s.currentTrack && !s.currentRadio) return; - const delayMs = Math.max(500, Math.round(Number(seconds) * 1000)); + const delayMs = scheduleDelayMsFromSeconds(seconds); const startedAt = Date.now(); - const at = startedAt + delayMs; + const at = scheduleDeadlineMs(startedAt, seconds); set({ scheduledResumeAtMs: at, scheduledResumeStartMs: startedAt }); scheduleResumeTimer(delayMs, () => { set({ scheduledResumeAtMs: null, scheduledResumeStartMs: null }); diff --git a/src/utils/format/playbackScheduleFormat.ts b/src/utils/format/playbackScheduleFormat.ts index a8c44b48..9b2e34ee 100644 --- a/src/utils/format/playbackScheduleFormat.ts +++ b/src/utils/format/playbackScheduleFormat.ts @@ -45,6 +45,10 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null { const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null; const [nowMs, setNowMs] = useState(() => Date.now()); const windowHidden = useWindowVisibility(); + useEffect(() => { + if (deadlineMs == null) return; + setNowMs(Date.now()); + }, [deadlineMs]); useEffect(() => { if (deadlineMs == null || windowHidden) return; const id = window.setInterval(() => { diff --git a/src/utils/playback/playbackScheduleDelay.test.ts b/src/utils/playback/playbackScheduleDelay.test.ts new file mode 100644 index 00000000..812a2a3c --- /dev/null +++ b/src/utils/playback/playbackScheduleDelay.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_PLAYBACK_SCHEDULE_DELAY_MS, + MAX_PLAYBACK_SCHEDULE_MINUTES, + isValidPlaybackSchedulePreviewTimestamp, + parsePlaybackDelayCustomMinutes, + scheduleDeadlineMs, + scheduleDelayMsFromSeconds, + scheduleSecondsFromCustomMinutes, +} from './playbackScheduleDelay'; + +describe('parsePlaybackDelayCustomMinutes', () => { + it('accepts fractional minutes', () => { + expect(parsePlaybackDelayCustomMinutes('0.1')).toBe(0.1); + expect(parsePlaybackDelayCustomMinutes('0,01')).toBe(0.01); + }); + + it('rejects empty, zero, and non-numeric input', () => { + expect(parsePlaybackDelayCustomMinutes('')).toBeNull(); + expect(parsePlaybackDelayCustomMinutes('0')).toBeNull(); + expect(parsePlaybackDelayCustomMinutes('abc')).toBeNull(); + }); + + it('rejects values above the setTimeout-safe minute cap', () => { + expect(parsePlaybackDelayCustomMinutes('99999999999')).toBeNull(); + expect(parsePlaybackDelayCustomMinutes(String(MAX_PLAYBACK_SCHEDULE_MINUTES + 1))).toBeNull(); + }); +}); + +describe('scheduleSecondsFromCustomMinutes', () => { + it('rounds fractional minutes to whole seconds', () => { + expect(scheduleSecondsFromCustomMinutes(0.1)).toBe(6); + expect(scheduleSecondsFromCustomMinutes(0.01)).toBe(1); + }); + + it('never returns zero', () => { + expect(scheduleSecondsFromCustomMinutes(0.001)).toBe(1); + }); +}); + +describe('scheduleDelayMsFromSeconds', () => { + it('enforces the minimum delay and caps at the browser limit', () => { + expect(scheduleDelayMsFromSeconds(0)).toBe(500); + expect(scheduleDelayMsFromSeconds(6)).toBe(6000); + expect(scheduleDelayMsFromSeconds(MAX_PLAYBACK_SCHEDULE_DELAY_MS / 1000)).toBe( + MAX_PLAYBACK_SCHEDULE_DELAY_MS, + ); + }); +}); + +describe('scheduleDeadlineMs', () => { + it('uses the same clamped delay as the armed timer', () => { + const startedAt = 1_700_000_000_000; + expect(scheduleDeadlineMs(startedAt, 6)).toBe(startedAt + 6000); + expect(scheduleDeadlineMs(startedAt, scheduleSecondsFromCustomMinutes(0.01))).toBe(startedAt + 1000); + }); +}); + +describe('isValidPlaybackSchedulePreviewTimestamp', () => { + it('accepts ordinary future timestamps and rejects invalid ones', () => { + expect(isValidPlaybackSchedulePreviewTimestamp(Date.now() + 60_000)).toBe(true); + expect(isValidPlaybackSchedulePreviewTimestamp(Number.NaN)).toBe(false); + expect(isValidPlaybackSchedulePreviewTimestamp(Number.POSITIVE_INFINITY)).toBe(false); + }); +}); diff --git a/src/utils/playback/playbackScheduleDelay.ts b/src/utils/playback/playbackScheduleDelay.ts new file mode 100644 index 00000000..59034cde --- /dev/null +++ b/src/utils/playback/playbackScheduleDelay.ts @@ -0,0 +1,42 @@ +/** Browser `setTimeout` upper bound (~24.8 days). Larger values fire immediately. */ +export const MAX_PLAYBACK_SCHEDULE_DELAY_MS = 2_147_483_647; + +/** Largest custom delay (minutes) that fits in `MAX_PLAYBACK_SCHEDULE_DELAY_MS`. */ +export const MAX_PLAYBACK_SCHEDULE_MINUTES = Math.floor(MAX_PLAYBACK_SCHEDULE_DELAY_MS / 60_000); + +const MIN_PLAYBACK_SCHEDULE_DELAY_MS = 500; + +/** Parse the modal's custom minutes field; rejects absurd values that break timers or Date preview. */ +export function parsePlaybackDelayCustomMinutes(raw: string): number | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const n = Number.parseFloat(trimmed.replace(',', '.')); + if (!Number.isFinite(n) || n <= 0) return null; + if (n > MAX_PLAYBACK_SCHEDULE_MINUTES) return null; + return n; +} + +/** Convert fractional minutes to whole seconds (minimum 1 s). */ +export function scheduleSecondsFromCustomMinutes(minutes: number): number { + return Math.max(1, Math.round(minutes * 60)); +} + +/** Clamp seconds to the same delay the store arms on `setTimeout`. */ +export function scheduleDelayMsFromSeconds(seconds: number): number { + const rawMs = Math.round(Number(seconds) * 1000); + if (!Number.isFinite(rawMs) || rawMs <= 0) { + return MIN_PLAYBACK_SCHEDULE_DELAY_MS; + } + return Math.min( + MAX_PLAYBACK_SCHEDULE_DELAY_MS, + Math.max(MIN_PLAYBACK_SCHEDULE_DELAY_MS, rawMs), + ); +} + +export function scheduleDeadlineMs(startedAtMs: number, seconds: number): number { + return startedAtMs + scheduleDelayMsFromSeconds(seconds); +} + +export function isValidPlaybackSchedulePreviewTimestamp(atMs: number): boolean { + return Number.isFinite(atMs) && atMs > 0 && atMs <= 8.64e15; +}