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
+210 -38
View File
@@ -2052,14 +2052,102 @@
.playback-delay-modal {
max-height: min(85vh, 560px);
padding: var(--space-6);
position: relative;
overflow: hidden;
animation: playback-delay-modal-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes playback-delay-modal-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Mood-tint: subtle gradient overlay behind content, differs per mode. */
.playback-delay-modal::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.6;
background: radial-gradient(
ellipse at top right,
color-mix(in srgb, var(--pd-accent, var(--accent)) 14%, transparent) 0%,
transparent 55%
);
z-index: 0;
}
/* Raise content above the ::before mood tint, but do NOT touch .modal-close
— it must keep its absolute top-right positioning. */
.playback-delay-modal > *:not(.modal-close) { position: relative; z-index: 1; }
/* Scoped polish for the close button in this modal: circular glass chip with
subtle hover lift + rotation. Does not touch the global .modal-close, so
other modals keep their existing look. */
.playback-delay-modal > .modal-close {
z-index: 2;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-muted);
transition:
background 180ms ease,
border-color 180ms ease,
color 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.playback-delay-modal > .modal-close:hover {
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 16%, transparent);
border-color: color-mix(in srgb, var(--pd-accent, var(--accent)) 40%, transparent);
color: var(--pd-accent, var(--accent));
transform: rotate(90deg);
}
.playback-delay-modal > .modal-close:focus-visible {
outline: 2px solid color-mix(in srgb, var(--pd-accent, var(--accent)) 60%, transparent);
outline-offset: 2px;
}
.playback-delay-modal > .modal-close:active {
transform: rotate(90deg) scale(0.94);
}
.playback-delay-modal--pause { --pd-accent: var(--ctp-lavender, #b4befe); }
.playback-delay-modal--start { --pd-accent: var(--ctp-peach, #fab387); }
.playback-delay-modal--idle { --pd-accent: var(--ctp-overlay1, #7f849c); }
.playback-delay-modal__head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 var(--space-4);
padding-right: 2rem;
}
.playback-delay-modal__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 18%, transparent);
color: var(--pd-accent, var(--accent));
flex-shrink: 0;
}
.playback-delay-modal__title {
margin: 0 0 var(--space-4);
padding-right: 2rem;
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.playback-delay-modal-overlay--anchored {
@@ -2074,39 +2162,112 @@
vertical-align: middle;
}
.playback-schedule-badge--floating {
/* ── Custom-minutes input with inline "min" suffix ──────────────────── */
.playback-delay-custom__field {
position: relative;
flex: 1 1 100px;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
transform-origin: top right;
min-width: 1.55rem;
height: 1.45rem;
padding: 0 5px;
border-radius: 999px;
font-size: 0.6rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
color: var(--text-primary);
background: color-mix(in srgb, var(--ctp-surface0) 88%, var(--text-primary) 4%);
border: 1px solid var(--border-subtle, var(--border));
box-shadow: 0 1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
pointer-events: none;
white-space: nowrap;
}
.playback-schedule-badge--floating.playback-schedule-badge--fs {
font-size: 0.64rem;
min-width: 1.65rem;
height: 1.5rem;
padding: 0 6px;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
.playback-delay-custom__field .playback-delay-custom__input {
flex: 1 1 auto;
padding-right: 2.4rem;
}
.playback-delay-custom__suffix {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 0.78rem;
color: var(--text-muted);
pointer-events: none;
opacity: 0.7;
letter-spacing: 0.02em;
}
/* ── Live preview: "Pauses at 23:47" ────────────────────────────────── */
.playback-delay-preview {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: var(--space-3);
min-height: 1.1rem;
font-size: 0.82rem;
color: var(--text-muted);
transition: opacity 150ms ease;
}
.playback-delay-preview[data-empty="true"] { opacity: 0; }
.playback-delay-preview[data-empty="false"] { opacity: 1; }
.playback-delay-preview__label { letter-spacing: 0.01em; }
.playback-delay-preview__time {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--pd-accent, var(--accent));
letter-spacing: -0.01em;
}
/* ── Circular progress ring around play/pause ────────────────────────── */
.playback-schedule-ring {
display: block;
}
.playback-schedule-ring__svg {
display: block;
overflow: visible;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 42%, transparent));
}
.playback-schedule-ring__track {
stroke: color-mix(in srgb, var(--text-primary) 10%, transparent);
}
.playback-schedule-ring__fill {
transition: stroke-dashoffset 480ms linear;
}
/* Unified accent gradient for both modes — the Moon / Sunrise icon in the
button carries the mode distinction, so the ring stays consistent with
the rest of the app. */
.playback-schedule-ring__grad-a { stop-color: var(--accent); }
.playback-schedule-ring__grad-b { stop-color: var(--ctp-lavender, #b4befe); }
/* Replaces the Play/Pause icon while a schedule timer is armed. Two-line
stack: mode icon (Moon for sleep, Sunrise for delayed-start) above the
countdown text. The mode icon + ring colour jointly signal which timer
is running. Three size variants mirror the three player views. */
.player-btn-schedule-stack {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
line-height: 1;
}
.player-btn-schedule-time {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
min-width: 2.2em;
text-align: center;
color: currentColor;
}
.player-btn-schedule-stack--fs { gap: 3px; }
.player-btn-schedule-time--fs { font-size: 0.92rem; }
.player-btn-schedule-stack--mobile { gap: 3px; }
.player-btn-schedule-time--mobile { font-size: 1rem; }
.playback-delay-section__head--tight {
display: flex;
align-items: center;
@@ -2197,23 +2358,34 @@
.playback-delay-chip {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 0.8rem;
padding: 7px 12px;
font-size: 0.82rem;
font-weight: 500;
background: var(--ctp-surface1);
color: var(--text-primary);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
transition:
background 150ms ease,
border-color 150ms ease,
color 150ms ease,
transform 120ms ease,
box-shadow 150ms ease;
}
.playback-delay-chip:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--ctp-surface1));
border-color: color-mix(in srgb, var(--pd-accent, var(--accent)) 55%, var(--border));
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 14%, var(--ctp-surface1));
color: var(--pd-accent, var(--accent));
transform: translateY(-1px);
box-shadow: 0 3px 10px color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, transparent);
}
.playback-delay-chip:active { transform: translateY(0); }
.playback-delay-chip--on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--ctp-surface1));
color: var(--accent);
border-color: var(--pd-accent, var(--accent));
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, var(--ctp-surface1));
color: var(--pd-accent, var(--accent));
}
.playback-delay-actions {