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
+12 -3
View File
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import { import {
Play, Pause, SkipBack, SkipForward, Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
Moon, Sunrise,
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
@@ -17,6 +18,7 @@ import { EaseScroller, targetForFraction } from '../utils/easeScroll';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -595,6 +597,7 @@ const FsPlayBtn = memo(function FsPlayBtn({
const togglePlay = usePlayerStore(s => s.togglePlay); const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const playSlotRef = useRef<HTMLSpanElement>(null); const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
return ( return (
<> <>
<span ref={playSlotRef} className="playback-transport-play-wrap"> <span ref={playSlotRef} className="playback-transport-play-wrap">
@@ -605,9 +608,15 @@ const FsPlayBtn = memo(function FsPlayBtn({
{...playPauseBind} {...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')} aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')} data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
> >
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />} {scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--fs`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={12} strokeWidth={2.5} />
: <Sunrise size={12} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button> </button>
</span> </span>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} /> <PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
+11 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { import {
ChevronDown, Play, Pause, SkipBack, SkipForward, ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X, Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore'; import { usePlayerStore, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
@@ -12,6 +13,7 @@ import LyricsPane from './LyricsPane';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
// ── Color extraction ────────────────────────────────────────────────────────── // ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation, // Samples a 16×16 canvas to find the most vibrant (highest-saturation,
@@ -168,6 +170,7 @@ export default function MobilePlayerView() {
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null); const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null); const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const next = usePlayerStore(s => s.next); const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous); const previous = usePlayerStore(s => s.previous);
const seek = usePlayerStore(s => s.seek); const seek = usePlayerStore(s => s.seek);
@@ -386,9 +389,15 @@ export default function MobilePlayerView() {
type="button" type="button"
{...playPauseBind} {...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')} aria-label={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
> >
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />} {scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--mobile`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={13} strokeWidth={2.5} />
: <Sunrise size={13} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--mobile">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button> </button>
</span> </span>
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}> <button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
+112 -71
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X } from 'lucide-react'; import { X, Moon, Sunrise } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -8,6 +8,11 @@ import { useShallow } from 'zustand/react/shallow';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatClockTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
/** One tap = schedule; custom minutes still covers any duration. */ /** One tap = schedule; custom minutes still covers any duration. */
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const; const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
@@ -72,10 +77,22 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
const [nowTick, setNowTick] = useState(() => Date.now()); const [nowTick, setNowTick] = useState(() => Date.now());
const [posTick, setPosTick] = useState(0); const [posTick, setPosTick] = useState(0);
const [customMinutes, setCustomMinutes] = useState(''); const [customMinutes, setCustomMinutes] = useState('');
/** Preset-seconds the user is currently hovering — drives the live "Pauses at HH:MM" preview. */
const [hoverSeconds, setHoverSeconds] = useState<number | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setCustomMinutes(''); setCustomMinutes('');
setHoverSeconds(null);
}, [open]);
// While modal is open, refresh the "now" tick every second so the live
// preview clock stays accurate.
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNowTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
@@ -133,11 +150,27 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
const heading = const heading =
canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle'); canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle');
// Mode determines icon + colour accent ("mood") of the modal.
const mode: 'pause' | 'start' | 'idle' =
canPauseLater ? 'pause' : canStartLater ? 'start' : 'idle';
const HeadingIcon = mode === 'pause' ? Moon : mode === 'start' ? Sunrise : null;
// Live preview: seconds that would be applied right now if the user clicked.
// Priority: hovered chip → typed custom minutes → nothing.
const previewSeconds = hoverSeconds ?? customSeconds;
const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null;
const previewClock = previewAtMs != null ? formatClockTime(previewAtMs) : null;
if (!open) return null; if (!open) return null;
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' }; const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle; const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle;
const scheduledAt = canPauseLater ? scheduledPauseAtMs : canStartLater ? scheduledResumeAtMs : null;
const clearScheduled = canPauseLater ? clearScheduledPause : canStartLater ? clearScheduledResume : null;
const cancelLabel = canPauseLater ? t('player.delayCancelPause') : t('player.delayCancelStart');
const apply = canPauseLater ? applyPause : applyStart;
return createPortal( return createPortal(
<div <div
className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`} className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`}
@@ -152,67 +185,103 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
} }
> >
<div <div
className="modal-content playback-delay-modal" className={`modal-content playback-delay-modal playback-delay-modal--${mode}`}
data-pd-mode={mode}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
style={panelStyle} style={panelStyle}
> >
<button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}> <button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}>
<X size={18} /> <X size={18} />
</button> </button>
<h3 id="playback-delay-modal-title" className="playback-delay-modal__title">
{heading}
</h3>
{canPauseLater && ( <div className="playback-delay-modal__head">
{HeadingIcon && (
<span className="playback-delay-modal__icon" aria-hidden="true">
<HeadingIcon size={18} />
</span>
)}
<h3 id="playback-delay-modal-title" className="playback-delay-modal__title">
{heading}
</h3>
</div>
{(canPauseLater || canStartLater) && (
<> <>
{scheduledPauseAtMs != null && ( {scheduledAt != null && (
<div className="playback-delay-section__head playback-delay-section__head--tight"> <div className="playback-delay-section__head playback-delay-section__head--tight">
<span className="playback-delay-section__countdown"> <span className="playback-delay-section__countdown">
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, nowTick)} {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)}
</span> </span>
<button {clearScheduled && (
type="button" <button
className="btn btn-ghost btn-sm playback-delay-inline-cancel" type="button"
aria-label={t('player.delayCancelPause')} className="btn btn-ghost btn-sm playback-delay-inline-cancel"
onClick={() => clearScheduledPause()} aria-label={cancelLabel}
> onClick={() => clearScheduled()}
{t('player.delayCancel')} >
</button> {t('player.delayCancel')}
</button>
)}
</div> </div>
)} )}
<div className="playback-delay-chips playback-delay-chips--compact"> <div
className="playback-delay-chips playback-delay-chips--compact"
onMouseLeave={() => setHoverSeconds(null)}
>
{PRESET_SECONDS.map(sec => ( {PRESET_SECONDS.map(sec => (
<button key={`p-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyPause(sec)}> <button
key={`pr-${sec}`}
type="button"
className="playback-delay-chip"
onMouseEnter={() => setHoverSeconds(sec)}
onFocus={() => setHoverSeconds(sec)}
onBlur={() => setHoverSeconds(null)}
onClick={() => apply(sec)}
>
{formatPresetLabel(sec, t)} {formatPresetLabel(sec, t)}
</button> </button>
))} ))}
</div> </div>
</>
)}
{canStartLater && ( <div className="playback-delay-custom playback-delay-custom--inline">
<> <div className="playback-delay-custom__field">
{scheduledResumeAtMs != null && ( <input
<div className="playback-delay-section__head playback-delay-section__head--tight"> ref={customInputRef}
<span className="playback-delay-section__countdown"> id="playback-delay-custom-min"
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledResumeAtMs, nowTick)} type="text"
</span> inputMode="decimal"
<button className="playback-delay-custom__input"
type="button" placeholder={t('player.delayCustomPlaceholder')}
className="btn btn-ghost btn-sm playback-delay-inline-cancel" value={customMinutes}
aria-label={t('player.delayCancelStart')} onChange={e => setCustomMinutes(e.target.value)}
onClick={() => clearScheduledResume()} aria-label={t('player.delayCustomMinutes')}
> />
{t('player.delayCancel')} <span className="playback-delay-custom__suffix" aria-hidden="true">min</span>
</button>
</div> </div>
)} <button
<div className="playback-delay-chips playback-delay-chips--compact"> type="button"
{PRESET_SECONDS.map(sec => ( className="btn btn-primary"
<button key={`s-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyStart(sec)}> disabled={customSeconds == null}
{formatPresetLabel(sec, t)} aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
</button> onClick={() => { if (customSeconds != null) apply(customSeconds); }}
))} >
{t('player.delayApply')}
</button>
</div>
<div
className="playback-delay-preview"
aria-live="polite"
data-empty={previewClock == null ? 'true' : 'false'}
>
{previewClock != null && (
<>
<span className="playback-delay-preview__label">
{canPauseLater ? t('player.delayPreviewPause') : t('player.delayPreviewStart')}
</span>
<span className="playback-delay-preview__time">{previewClock}</span>
</>
)}
</div> </div>
</> </>
)} )}
@@ -223,34 +292,6 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
<p className="playback-delay-muted">{t('player.delayInactiveStart')}</p> <p className="playback-delay-muted">{t('player.delayInactiveStart')}</p>
</div> </div>
)} )}
{(canPauseLater || canStartLater) && (
<div className="playback-delay-custom playback-delay-custom--inline">
<input
id="playback-delay-custom-min"
type="text"
inputMode="decimal"
className="playback-delay-custom__input"
placeholder={t('player.delayCustomPlaceholder')}
value={customMinutes}
onChange={e => setCustomMinutes(e.target.value)}
aria-label={t('player.delayCustomMinutes')}
/>
<button
type="button"
className="btn btn-primary"
disabled={customSeconds == null}
aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
onClick={() => {
if (customSeconds == null) return;
if (canPauseLater) applyPause(customSeconds);
else applyStart(customSeconds);
}}
>
{t('player.delayApply')}
</button>
</div>
)}
</div> </div>
</div>, </div>,
document.body, document.body,
+104 -22
View File
@@ -6,29 +6,46 @@ import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
export interface PlaybackScheduleBadgeProps { 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>; 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; className?: string;
} }
/** /**
* Small pill at the top-right of play/pause (overlapping) when a timer is armed. * Circular progress ring around the play/pause button, portaled to document.body
* Portaled to `document.body` so it is not clipped by `contain: paint` on the player bar. * 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) { export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore( const {
isPlaying,
scheduledPauseAtMs,
scheduledPauseStartMs,
scheduledResumeAtMs,
scheduledResumeStartMs,
} = usePlayerStore(
useShallow(s => ({ useShallow(s => ({
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
scheduledPauseAtMs: s.scheduledPauseAtMs, scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledPauseStartMs: s.scheduledPauseStartMs,
scheduledResumeAtMs: s.scheduledResumeAtMs, scheduledResumeAtMs: s.scheduledResumeAtMs,
scheduledResumeStartMs: s.scheduledResumeStartMs,
})), })),
); );
// Active timer: pause if playing, resume if paused.
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs; const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
const startMs = isPlaying ? scheduledPauseStartMs : scheduledResumeStartMs;
const [nowMs, setNowMs] = useState(() => Date.now()); 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(() => { useEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null) return;
@@ -42,13 +59,10 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
if (!el) return; if (!el) return;
const sync = () => { const sync = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
setPanelStyle({ setAnchorRect({
position: 'fixed', left: r.left + r.width / 2,
left: r.right, top: r.top + r.height / 2,
top: r.top, size: Math.max(r.width, r.height),
transform: 'translate(-88%, -36%)',
zIndex: 9998,
visibility: 'visible',
}); });
}; };
sync(); sync();
@@ -62,19 +76,87 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
}; };
}, [deadlineMs, layoutAnchorRef]); }, [deadlineMs, layoutAnchorRef]);
if (deadlineMs == null) return null; if (deadlineMs == null || startMs == null || !anchorRect) return null;
const text = formatPlaybackScheduleRemaining(deadlineMs, nowMs); const totalMs = Math.max(1, deadlineMs - startMs);
const label = const remainingMs = Math.max(0, deadlineMs - nowMs);
isPlaying && scheduledPauseAtMs != null const progress = Math.min(1, Math.max(0, 1 - remainingMs / totalMs)); // 0 → just armed, 1 → fires now
? `${t('player.delayPauseSection')}: ${t('player.delayIn')} ${text}` const nearEnd = remainingMs / totalMs < 0.1;
: `${t('player.delayStartSection')}: ${t('player.delayIn')} ${text}`;
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( return createPortal(
<span className={pillClass} style={panelStyle} aria-live="polite" title={label}> <span
{text} 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>, </span>,
document.body, document.body,
); );
+11 -3
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast, Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2, ArrowLeftRight, PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
} from 'lucide-react'; } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
@@ -24,6 +24,7 @@ import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -142,6 +143,7 @@ export default function PlayerBar() {
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null); const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null); const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const isRadio = !!currentRadio; const isRadio = !!currentRadio;
@@ -315,9 +317,15 @@ export default function PlayerBar() {
{...playPauseBind} {...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')} aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')} data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
title={isPlaying ? t('player.pause') : t('player.play')}
> >
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />} {scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={10} strokeWidth={2.5} />
: <Sunrise size={10} strokeWidth={2.5} />}
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button> </button>
</span> </span>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}> <button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
+2
View File
@@ -1129,6 +1129,8 @@ export const deTranslation = {
delayModalTitle: 'Timer', delayModalTitle: 'Timer',
delayPauseSection: 'Pause nach', delayPauseSection: 'Pause nach',
delayStartSection: 'Start nach', delayStartSection: 'Start nach',
delayPreviewPause: 'Pause um',
delayPreviewStart: 'Start um',
delaySchedulePause: 'Pause planen', delaySchedulePause: 'Pause planen',
delayScheduleStart: 'Start planen', delayScheduleStart: 'Start planen',
delayCancelPause: 'Pause-Timer abbrechen', delayCancelPause: 'Pause-Timer abbrechen',
+2
View File
@@ -1131,6 +1131,8 @@ export const enTranslation = {
delayModalTitle: 'Timer', delayModalTitle: 'Timer',
delayPauseSection: 'Pause after', delayPauseSection: 'Pause after',
delayStartSection: 'Start after', delayStartSection: 'Start after',
delayPreviewPause: 'Pauses at',
delayPreviewStart: 'Starts at',
delaySchedulePause: 'Schedule pause', delaySchedulePause: 'Schedule pause',
delayScheduleStart: 'Schedule start', delayScheduleStart: 'Schedule start',
delayCancelPause: 'Cancel pause timer', delayCancelPause: 'Cancel pause timer',
+2
View File
@@ -1122,6 +1122,8 @@ export const esTranslation = {
delayModalTitle: 'Temporizador', delayModalTitle: 'Temporizador',
delayPauseSection: 'Pausar después de', delayPauseSection: 'Pausar después de',
delayStartSection: 'Iniciar después de', delayStartSection: 'Iniciar después de',
delayPreviewPause: 'Pausa a las',
delayPreviewStart: 'Inicio a las',
delaySchedulePause: 'Programar pausa', delaySchedulePause: 'Programar pausa',
delayScheduleStart: 'Programar inicio', delayScheduleStart: 'Programar inicio',
delayCancelPause: 'Cancelar temporizador de pausa', delayCancelPause: 'Cancelar temporizador de pausa',
+2
View File
@@ -1117,6 +1117,8 @@ export const frTranslation = {
delayModalTitle: 'Minuteur', delayModalTitle: 'Minuteur',
delayPauseSection: 'Pause après', delayPauseSection: 'Pause après',
delayStartSection: 'Démarrage après', delayStartSection: 'Démarrage après',
delayPreviewPause: 'Pause à',
delayPreviewStart: 'Démarrage à',
delaySchedulePause: 'Programmer la pause', delaySchedulePause: 'Programmer la pause',
delayScheduleStart: 'Programmer le démarrage', delayScheduleStart: 'Programmer le démarrage',
delayCancelPause: 'Annuler le minuteur de pause', delayCancelPause: 'Annuler le minuteur de pause',
+2
View File
@@ -1116,6 +1116,8 @@ export const nbTranslation = {
delayModalTitle: 'Tidsur', delayModalTitle: 'Tidsur',
delayPauseSection: 'Pause etter', delayPauseSection: 'Pause etter',
delayStartSection: 'Start etter', delayStartSection: 'Start etter',
delayPreviewPause: 'Pause kl.',
delayPreviewStart: 'Start kl.',
delaySchedulePause: 'Planlegg pause', delaySchedulePause: 'Planlegg pause',
delayScheduleStart: 'Planlegg start', delayScheduleStart: 'Planlegg start',
delayCancelPause: 'Avbryt pause-timer', delayCancelPause: 'Avbryt pause-timer',
+2
View File
@@ -1116,6 +1116,8 @@ export const nlTranslation = {
delayModalTitle: 'Timer', delayModalTitle: 'Timer',
delayPauseSection: 'Pauzeren na', delayPauseSection: 'Pauzeren na',
delayStartSection: 'Starten na', delayStartSection: 'Starten na',
delayPreviewPause: 'Pauze om',
delayPreviewStart: 'Start om',
delaySchedulePause: 'Pauze plannen', delaySchedulePause: 'Pauze plannen',
delayScheduleStart: 'Start plannen', delayScheduleStart: 'Start plannen',
delayCancelPause: 'Pauze-timer annuleren', delayCancelPause: 'Pauze-timer annuleren',
+2
View File
@@ -1194,6 +1194,8 @@ export const ruTranslation = {
delayModalTitle: 'Таймер', delayModalTitle: 'Таймер',
delayPauseSection: 'Пауза через', delayPauseSection: 'Пауза через',
delayStartSection: 'Старт через', delayStartSection: 'Старт через',
delayPreviewPause: 'Пауза в',
delayPreviewStart: 'Старт в',
delaySchedulePause: 'Запланировать паузу', delaySchedulePause: 'Запланировать паузу',
delayScheduleStart: 'Запланировать старт', delayScheduleStart: 'Запланировать старт',
delayCancelPause: 'Отменить таймер паузы', delayCancelPause: 'Отменить таймер паузы',
+2
View File
@@ -1111,6 +1111,8 @@ export const zhTranslation = {
delayModalTitle: '定时', delayModalTitle: '定时',
delayPauseSection: '多久后暂停', delayPauseSection: '多久后暂停',
delayStartSection: '多久后开始', delayStartSection: '多久后开始',
delayPreviewPause: '暂停于',
delayPreviewStart: '开始于',
delaySchedulePause: '安排暂停', delaySchedulePause: '安排暂停',
delayScheduleStart: '安排开始', delayScheduleStart: '安排开始',
delayCancelPause: '取消暂停定时', delayCancelPause: '取消暂停定时',
+22 -12
View File
@@ -177,8 +177,12 @@ interface PlayerState {
togglePlay: () => void; togglePlay: () => void;
/** Wall-clock ms when auto-pause fires, or null. */ /** Wall-clock ms when auto-pause fires, or null. */
scheduledPauseAtMs: number | null; scheduledPauseAtMs: number | null;
/** Wall-clock ms when the current auto-pause timer was armed (for progress-ring totals). */
scheduledPauseStartMs: number | null;
/** Wall-clock ms when auto-resume fires, or null. */ /** Wall-clock ms when auto-resume fires, or null. */
scheduledResumeAtMs: number | null; scheduledResumeAtMs: number | null;
/** Wall-clock ms when the current auto-resume timer was armed (for progress-ring totals). */
scheduledResumeStartMs: number | null;
schedulePauseIn: (seconds: number) => void; schedulePauseIn: (seconds: number) => void;
scheduleResumeIn: (seconds: number) => void; scheduleResumeIn: (seconds: number) => void;
clearScheduledPause: () => void; clearScheduledPause: () => void;
@@ -1051,7 +1055,9 @@ export const usePlayerStore = create<PlayerState>()(
isQueueVisible: true, isQueueVisible: true,
isFullscreenOpen: false, isFullscreenOpen: false,
scheduledPauseAtMs: null, scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null, scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
repeatMode: 'off', repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null }, contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
@@ -1147,7 +1153,9 @@ export const usePlayerStore = create<PlayerState>()(
currentPlaybackSource: null, currentPlaybackSource: null,
enginePreloadedTrackId: null, enginePreloadedTrackId: null,
scheduledPauseAtMs: null, scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null, scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
}); });
}, },
@@ -1156,7 +1164,7 @@ export const usePlayerStore = create<PlayerState>()(
const { volume } = get(); const { volume } = get();
++playGeneration; ++playGeneration;
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
isAudioPaused = false; isAudioPaused = false;
clearRadioReconnectTimer(); clearRadioReconnectTimer();
radioReconnectCount = 0; radioReconnectCount = 0;
@@ -1202,7 +1210,7 @@ export const usePlayerStore = create<PlayerState>()(
} }
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = ++playGeneration; const gen = ++playGeneration;
isAudioPaused = false; isAudioPaused = false;
@@ -1363,7 +1371,7 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_pause').catch(console.error); invoke('audio_pause').catch(console.error);
isAudioPaused = true; isAudioPaused = true;
} }
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledResumeAtMs: null }); set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
}, },
resetAudioPause: () => { resetAudioPause: () => {
@@ -1372,7 +1380,7 @@ export const usePlayerStore = create<PlayerState>()(
resume: () => { resume: () => {
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
if (get().currentRadio) { if (get().currentRadio) {
radioAudio.play().catch(console.error); radioAudio.play().catch(console.error);
set({ isPlaying: true }); set({ isPlaying: true });
@@ -1467,12 +1475,12 @@ export const usePlayerStore = create<PlayerState>()(
clearScheduledPause: () => { clearScheduledPause: () => {
clearScheduledPauseTimers(); clearScheduledPauseTimers();
set({ scheduledPauseAtMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null });
}, },
clearScheduledResume: () => { clearScheduledResume: () => {
clearScheduledResumeTimers(); clearScheduledResumeTimers();
set({ scheduledResumeAtMs: null }); set({ scheduledResumeAtMs: null, scheduledResumeStartMs: null });
}, },
schedulePauseIn: (seconds) => { schedulePauseIn: (seconds) => {
@@ -1480,11 +1488,12 @@ export const usePlayerStore = create<PlayerState>()(
if (!s.isPlaying) return; if (!s.isPlaying) return;
clearScheduledPauseTimers(); clearScheduledPauseTimers();
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000)); const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
const at = Date.now() + delayMs; const startedAt = Date.now();
set({ scheduledPauseAtMs: at }); const at = startedAt + delayMs;
set({ scheduledPauseAtMs: at, scheduledPauseStartMs: startedAt });
scheduledPauseTimer = window.setTimeout(() => { scheduledPauseTimer = window.setTimeout(() => {
scheduledPauseTimer = null; scheduledPauseTimer = null;
set({ scheduledPauseAtMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null });
get().pause(); get().pause();
}, delayMs) as unknown as number; }, delayMs) as unknown as number;
}, },
@@ -1495,11 +1504,12 @@ export const usePlayerStore = create<PlayerState>()(
if (!s.currentTrack && !s.currentRadio) return; if (!s.currentTrack && !s.currentRadio) return;
clearScheduledResumeTimers(); clearScheduledResumeTimers();
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000)); const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
const at = Date.now() + delayMs; const startedAt = Date.now();
set({ scheduledResumeAtMs: at }); const at = startedAt + delayMs;
set({ scheduledResumeAtMs: at, scheduledResumeStartMs: startedAt });
scheduledResumeTimer = window.setTimeout(() => { scheduledResumeTimer = window.setTimeout(() => {
scheduledResumeTimer = null; scheduledResumeTimer = null;
set({ scheduledResumeAtMs: null }); set({ scheduledResumeAtMs: null, scheduledResumeStartMs: null });
get().resume(); get().resume();
}, delayMs) as unknown as number; }, delayMs) as unknown as number;
}, },
+210 -38
View File
@@ -2052,14 +2052,102 @@
.playback-delay-modal { .playback-delay-modal {
max-height: min(85vh, 560px); max-height: min(85vh, 560px);
padding: var(--space-6); 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 { .playback-delay-modal__title {
margin: 0 0 var(--space-4); margin: 0;
padding-right: 2rem;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em;
} }
.playback-delay-modal-overlay--anchored { .playback-delay-modal-overlay--anchored {
@@ -2074,39 +2162,112 @@
vertical-align: middle; 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; 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 { .playback-delay-custom__field .playback-delay-custom__input {
font-size: 0.64rem; flex: 1 1 auto;
min-width: 1.65rem; padding-right: 2.4rem;
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__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 { .playback-delay-section__head--tight {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2197,23 +2358,34 @@
.playback-delay-chip { .playback-delay-chip {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 6px 10px; padding: 7px 12px;
font-size: 0.8rem; font-size: 0.82rem;
font-weight: 500;
background: var(--ctp-surface1); background: var(--ctp-surface1);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; 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 { .playback-delay-chip:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); border-color: color-mix(in srgb, var(--pd-accent, var(--accent)) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--ctp-surface1)); 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 { .playback-delay-chip--on {
border-color: var(--accent); border-color: var(--pd-accent, var(--accent));
background: color-mix(in srgb, var(--accent) 18%, var(--ctp-surface1)); background: color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, var(--ctp-surface1));
color: var(--accent); color: var(--pd-accent, var(--accent));
} }
.playback-delay-actions { .playback-delay-actions {
+40
View File
@@ -1,3 +1,7 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */ /** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string { export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
if (deadlineMs == null) return ''; if (deadlineMs == null) return '';
@@ -11,3 +15,39 @@ export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs
} }
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
} }
export interface PlaybackScheduleInfo {
/** Formatted countdown, e.g. "4:32". */
remaining: string;
/** Which timer is running: `pause` = sleep-timer, `start` = delayed-start. */
mode: 'pause' | 'start';
}
/**
* Hook: returns the active playback-schedule countdown + its mode, or null
* when no timer is armed. Ticks every 500 ms so the caller never needs to
* set up its own interval. Used by the play/pause button to swap the
* Play/Pause icon for a mode-icon + countdown text while a timer runs.
*/
export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
})),
);
const mode: 'pause' | 'start' | null =
isPlaying && scheduledPauseAtMs != null ? 'pause'
: !isPlaying && scheduledResumeAtMs != null ? 'start'
: null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (deadlineMs == null) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500);
return () => window.clearInterval(id);
}, [deadlineMs]);
if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
}