mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user