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:
Frank Stellmacher
2026-04-23 00:13:28 +02:00
committed by GitHub
parent 624ce56faf
commit 694567843f
16 changed files with 538 additions and 151 deletions
+104 -22
View File
@@ -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,
);