mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(player): polish sleep-timer UI — circular ring + in-button countdown (#272)
Replaces the text-pill schedule badge with a circular SVG progress ring
around the play/pause button. Accent→lavender gradient stroke, counter-
clockwise depletion synced to the armed deadline, subtle accent glow.
While a timer is armed, the Play/Pause icon inside the button is
swapped for a two-line stack: a small Moon (sleep timer) or Sunrise
(delayed start) glyph above the countdown (m:ss / h:mm:ss). The icon
is the mode marker so the ring can stay unified with the rest of the
app's accent palette.
Redesigns the schedule modal with a mood-tinted header (Moon for
"Pause after", Sunrise for "Start after"), soft radial accent glow in
the background, slide-up open animation. Circular glass close-button
scoped to this modal, with hover rotation + focus ring. Custom-minutes
field gains an inline "min" suffix. A live preview line at the bottom
shows when the action fires ("Pauses at 23:47" / "Starts at 23:47"),
updating as the user hovers a preset or types. Chips gain a small
hover lift plus accent-coloured shadow.
Also fixes a pre-existing duplicate-tooltip on the play/pause button:
title= attributes removed alongside the data-tooltip (title violates
the project's "never native tooltips" rule, so both showed at once).
Adds scheduledPauseStartMs / scheduledResumeStartMs to the player
store so the progress ring has a total-duration baseline, set and
cleared alongside the existing deadline fields.
New usePlaybackScheduleRemaining hook wraps the store subscription
and 500 ms tick into a single hook used by all three player views
(PlayerBar / FullscreenPlayer / MobilePlayerView).
i18n: delayPreviewPause / delayPreviewStart keys across all 8 locales.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
624ce56faf
commit
694567843f
@@ -6,29 +6,46 @@ 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). */
|
||||
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
|
||||
layoutAnchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Extra classes on the portaled pill (e.g. fullscreen sizing). */
|
||||
/** Extra class on the portaled ring (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.
|
||||
* Circular progress ring around the play/pause button, portaled to document.body
|
||||
* so it is never clipped by `contain: paint` on the player bar.
|
||||
*
|
||||
* - Accent-coloured SVG stroke with a gradient; depletes as the deadline approaches.
|
||||
* - Colour shifts to a warm warning hue when <10 % of the scheduled time remains.
|
||||
* - The remaining time is rendered _inside_ the button (replaces the
|
||||
* Play/Pause icon) by the consuming view, not here — avoids the floating
|
||||
* pill clipping against the viewport edge.
|
||||
*/
|
||||
export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore(
|
||||
const {
|
||||
isPlaying,
|
||||
scheduledPauseAtMs,
|
||||
scheduledPauseStartMs,
|
||||
scheduledResumeAtMs,
|
||||
scheduledResumeStartMs,
|
||||
} = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
isPlaying: s.isPlaying,
|
||||
scheduledPauseAtMs: s.scheduledPauseAtMs,
|
||||
scheduledPauseStartMs: s.scheduledPauseStartMs,
|
||||
scheduledResumeAtMs: s.scheduledResumeAtMs,
|
||||
scheduledResumeStartMs: s.scheduledResumeStartMs,
|
||||
})),
|
||||
);
|
||||
|
||||
// Active timer: pause if playing, resume if paused.
|
||||
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
|
||||
const startMs = isPlaying ? scheduledPauseStartMs : scheduledResumeStartMs;
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
|
||||
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
@@ -42,13 +59,10 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
|
||||
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',
|
||||
setAnchorRect({
|
||||
left: r.left + r.width / 2,
|
||||
top: r.top + r.height / 2,
|
||||
size: Math.max(r.width, r.height),
|
||||
});
|
||||
};
|
||||
sync();
|
||||
@@ -62,19 +76,87 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
|
||||
};
|
||||
}, [deadlineMs, layoutAnchorRef]);
|
||||
|
||||
if (deadlineMs == null) return null;
|
||||
if (deadlineMs == null || startMs == null || !anchorRect) 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 totalMs = Math.max(1, deadlineMs - startMs);
|
||||
const remainingMs = Math.max(0, deadlineMs - nowMs);
|
||||
const progress = Math.min(1, Math.max(0, 1 - remainingMs / totalMs)); // 0 → just armed, 1 → fires now
|
||||
const nearEnd = remainingMs / totalMs < 0.1;
|
||||
|
||||
const pillClass = ['playback-schedule-badge', 'playback-schedule-badge--floating', className].filter(Boolean).join(' ');
|
||||
const label = isPlaying && scheduledPauseAtMs != null
|
||||
? `${t('player.delayPauseSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`
|
||||
: `${t('player.delayStartSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`;
|
||||
|
||||
// Ring sits snug around the button; diameter ~1.22× button size for breathing room.
|
||||
const ringSize = Math.round(anchorRect.size * 1.22);
|
||||
const strokeW = Math.max(2.5, ringSize / 28);
|
||||
const r = ringSize / 2 - strokeW / 2;
|
||||
const circ = 2 * Math.PI * r;
|
||||
// Reversed direction so the ring shrinks counter-clockwise from the top.
|
||||
const dashOffset = -circ * progress;
|
||||
|
||||
// Mode selects the gradient tint: pause = lavender, start = peach.
|
||||
const mode: 'pause' | 'start' = isPlaying ? 'pause' : 'start';
|
||||
// Uniqueish gradient id — multiple badges (player bar + fullscreen) can coexist.
|
||||
const gradId = `psy-sched-grad-${mode}`;
|
||||
|
||||
const wrapStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchorRect.left,
|
||||
top: anchorRect.top,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: ringSize,
|
||||
height: ringSize,
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<span className={pillClass} style={panelStyle} aria-live="polite" title={label}>
|
||||
{text}
|
||||
<span
|
||||
className={[
|
||||
'playback-schedule-ring',
|
||||
`playback-schedule-ring--${mode}`,
|
||||
nearEnd ? 'is-warn' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={wrapStyle}
|
||||
aria-label={label}
|
||||
>
|
||||
<svg
|
||||
className="playback-schedule-ring__svg"
|
||||
width={ringSize}
|
||||
height={ringSize}
|
||||
viewBox={`0 0 ${ringSize} ${ringSize}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" className="playback-schedule-ring__grad-a" />
|
||||
<stop offset="100%" className="playback-schedule-ring__grad-b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
className="playback-schedule-ring__track"
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth={strokeW}
|
||||
/>
|
||||
<circle
|
||||
className="playback-schedule-ring__fill"
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={`url(#${gradId})`}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${ringSize / 2} ${ringSize / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user