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:
@@ -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)**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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