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; +}