mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
|
||||
Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
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 PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -595,6 +597,7 @@ const FsPlayBtn = memo(function FsPlayBtn({
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
return (
|
||||
<>
|
||||
<span ref={playSlotRef} className="playback-transport-play-wrap">
|
||||
@@ -605,9 +608,15 @@ const FsPlayBtn = memo(function FsPlayBtn({
|
||||
{...playPauseBind}
|
||||
aria-label={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>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
@@ -12,6 +13,7 @@ import LyricsPane from './LyricsPane';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// 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 transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
@@ -386,9 +389,15 @@ export default function MobilePlayerView() {
|
||||
type="button"
|
||||
{...playPauseBind}
|
||||
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>
|
||||
</span>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
|
||||
@@ -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 { X } from 'lucide-react';
|
||||
import { X, Moon, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -8,6 +8,11 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import type { TFunction } from 'i18next';
|
||||
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. */
|
||||
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 [posTick, setPosTick] = useState(0);
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,11 +150,27 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
|
||||
const heading =
|
||||
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;
|
||||
|
||||
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
|
||||
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(
|
||||
<div
|
||||
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
|
||||
className="modal-content playback-delay-modal"
|
||||
className={`modal-content playback-delay-modal playback-delay-modal--${mode}`}
|
||||
data-pd-mode={mode}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={panelStyle}
|
||||
>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}>
|
||||
<X size={18} />
|
||||
</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">
|
||||
<span className="playback-delay-section__countdown">
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, nowTick)}
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
|
||||
aria-label={t('player.delayCancelPause')}
|
||||
onClick={() => clearScheduledPause()}
|
||||
>
|
||||
{t('player.delayCancel')}
|
||||
</button>
|
||||
{clearScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
|
||||
aria-label={cancelLabel}
|
||||
onClick={() => clearScheduled()}
|
||||
>
|
||||
{t('player.delayCancel')}
|
||||
</button>
|
||||
)}
|
||||
</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 => (
|
||||
<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)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canStartLater && (
|
||||
<>
|
||||
{scheduledResumeAtMs != null && (
|
||||
<div className="playback-delay-section__head playback-delay-section__head--tight">
|
||||
<span className="playback-delay-section__countdown">
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledResumeAtMs, nowTick)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
|
||||
aria-label={t('player.delayCancelStart')}
|
||||
onClick={() => clearScheduledResume()}
|
||||
>
|
||||
{t('player.delayCancel')}
|
||||
</button>
|
||||
<div className="playback-delay-custom playback-delay-custom--inline">
|
||||
<div className="playback-delay-custom__field">
|
||||
<input
|
||||
ref={customInputRef}
|
||||
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')}
|
||||
/>
|
||||
<span className="playback-delay-custom__suffix" aria-hidden="true">min</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="playback-delay-chips playback-delay-chips--compact">
|
||||
{PRESET_SECONDS.map(sec => (
|
||||
<button key={`s-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyStart(sec)}>
|
||||
{formatPresetLabel(sec, t)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={customSeconds == null}
|
||||
aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
@@ -223,34 +292,6 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
|
||||
<p className="playback-delay-muted">{t('player.delayInactiveStart')}</p>
|
||||
</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>,
|
||||
document.body,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
|
||||
PictureInPicture2, ArrowLeftRight,
|
||||
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -24,6 +24,7 @@ import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -142,6 +143,7 @@ export default function PlayerBar() {
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
@@ -315,9 +317,15 @@ export default function PlayerBar() {
|
||||
{...playPauseBind}
|
||||
aria-label={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>
|
||||
</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}>
|
||||
|
||||
@@ -1129,6 +1129,8 @@ export const deTranslation = {
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pause nach',
|
||||
delayStartSection: 'Start nach',
|
||||
delayPreviewPause: 'Pause um',
|
||||
delayPreviewStart: 'Start um',
|
||||
delaySchedulePause: 'Pause planen',
|
||||
delayScheduleStart: 'Start planen',
|
||||
delayCancelPause: 'Pause-Timer abbrechen',
|
||||
|
||||
@@ -1131,6 +1131,8 @@ export const enTranslation = {
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pause after',
|
||||
delayStartSection: 'Start after',
|
||||
delayPreviewPause: 'Pauses at',
|
||||
delayPreviewStart: 'Starts at',
|
||||
delaySchedulePause: 'Schedule pause',
|
||||
delayScheduleStart: 'Schedule start',
|
||||
delayCancelPause: 'Cancel pause timer',
|
||||
|
||||
@@ -1122,6 +1122,8 @@ export const esTranslation = {
|
||||
delayModalTitle: 'Temporizador',
|
||||
delayPauseSection: 'Pausar después de',
|
||||
delayStartSection: 'Iniciar después de',
|
||||
delayPreviewPause: 'Pausa a las',
|
||||
delayPreviewStart: 'Inicio a las',
|
||||
delaySchedulePause: 'Programar pausa',
|
||||
delayScheduleStart: 'Programar inicio',
|
||||
delayCancelPause: 'Cancelar temporizador de pausa',
|
||||
|
||||
@@ -1117,6 +1117,8 @@ export const frTranslation = {
|
||||
delayModalTitle: 'Minuteur',
|
||||
delayPauseSection: 'Pause après',
|
||||
delayStartSection: 'Démarrage après',
|
||||
delayPreviewPause: 'Pause à',
|
||||
delayPreviewStart: 'Démarrage à',
|
||||
delaySchedulePause: 'Programmer la pause',
|
||||
delayScheduleStart: 'Programmer le démarrage',
|
||||
delayCancelPause: 'Annuler le minuteur de pause',
|
||||
|
||||
@@ -1116,6 +1116,8 @@ export const nbTranslation = {
|
||||
delayModalTitle: 'Tidsur',
|
||||
delayPauseSection: 'Pause etter',
|
||||
delayStartSection: 'Start etter',
|
||||
delayPreviewPause: 'Pause kl.',
|
||||
delayPreviewStart: 'Start kl.',
|
||||
delaySchedulePause: 'Planlegg pause',
|
||||
delayScheduleStart: 'Planlegg start',
|
||||
delayCancelPause: 'Avbryt pause-timer',
|
||||
|
||||
@@ -1116,6 +1116,8 @@ export const nlTranslation = {
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pauzeren na',
|
||||
delayStartSection: 'Starten na',
|
||||
delayPreviewPause: 'Pauze om',
|
||||
delayPreviewStart: 'Start om',
|
||||
delaySchedulePause: 'Pauze plannen',
|
||||
delayScheduleStart: 'Start plannen',
|
||||
delayCancelPause: 'Pauze-timer annuleren',
|
||||
|
||||
@@ -1194,6 +1194,8 @@ export const ruTranslation = {
|
||||
delayModalTitle: 'Таймер',
|
||||
delayPauseSection: 'Пауза через',
|
||||
delayStartSection: 'Старт через',
|
||||
delayPreviewPause: 'Пауза в',
|
||||
delayPreviewStart: 'Старт в',
|
||||
delaySchedulePause: 'Запланировать паузу',
|
||||
delayScheduleStart: 'Запланировать старт',
|
||||
delayCancelPause: 'Отменить таймер паузы',
|
||||
|
||||
@@ -1111,6 +1111,8 @@ export const zhTranslation = {
|
||||
delayModalTitle: '定时',
|
||||
delayPauseSection: '多久后暂停',
|
||||
delayStartSection: '多久后开始',
|
||||
delayPreviewPause: '暂停于',
|
||||
delayPreviewStart: '开始于',
|
||||
delaySchedulePause: '安排暂停',
|
||||
delayScheduleStart: '安排开始',
|
||||
delayCancelPause: '取消暂停定时',
|
||||
|
||||
+22
-12
@@ -177,8 +177,12 @@ interface PlayerState {
|
||||
togglePlay: () => void;
|
||||
/** Wall-clock ms when auto-pause fires, or 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. */
|
||||
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;
|
||||
scheduleResumeIn: (seconds: number) => void;
|
||||
clearScheduledPause: () => void;
|
||||
@@ -1051,7 +1055,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
scheduledPauseAtMs: null,
|
||||
scheduledPauseStartMs: null,
|
||||
scheduledResumeAtMs: null,
|
||||
scheduledResumeStartMs: null,
|
||||
repeatMode: 'off',
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
|
||||
@@ -1147,7 +1153,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
scheduledPauseAtMs: null,
|
||||
scheduledPauseStartMs: null,
|
||||
scheduledResumeAtMs: null,
|
||||
scheduledResumeStartMs: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1156,7 +1164,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
isAudioPaused = false;
|
||||
clearRadioReconnectTimer();
|
||||
radioReconnectCount = 0;
|
||||
@@ -1202,7 +1210,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
@@ -1363,7 +1371,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
}
|
||||
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
},
|
||||
|
||||
resetAudioPause: () => {
|
||||
@@ -1372,7 +1380,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
resume: () => {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
set({ isPlaying: true });
|
||||
@@ -1467,12 +1475,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
clearScheduledPause: () => {
|
||||
clearScheduledPauseTimers();
|
||||
set({ scheduledPauseAtMs: null });
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null });
|
||||
},
|
||||
|
||||
clearScheduledResume: () => {
|
||||
clearScheduledResumeTimers();
|
||||
set({ scheduledResumeAtMs: null });
|
||||
set({ scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
},
|
||||
|
||||
schedulePauseIn: (seconds) => {
|
||||
@@ -1480,11 +1488,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (!s.isPlaying) return;
|
||||
clearScheduledPauseTimers();
|
||||
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
|
||||
const at = Date.now() + delayMs;
|
||||
set({ scheduledPauseAtMs: at });
|
||||
const startedAt = Date.now();
|
||||
const at = startedAt + delayMs;
|
||||
set({ scheduledPauseAtMs: at, scheduledPauseStartMs: startedAt });
|
||||
scheduledPauseTimer = window.setTimeout(() => {
|
||||
scheduledPauseTimer = null;
|
||||
set({ scheduledPauseAtMs: null });
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null });
|
||||
get().pause();
|
||||
}, delayMs) as unknown as number;
|
||||
},
|
||||
@@ -1495,11 +1504,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (!s.currentTrack && !s.currentRadio) return;
|
||||
clearScheduledResumeTimers();
|
||||
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
|
||||
const at = Date.now() + delayMs;
|
||||
set({ scheduledResumeAtMs: at });
|
||||
const startedAt = Date.now();
|
||||
const at = startedAt + delayMs;
|
||||
set({ scheduledResumeAtMs: at, scheduledResumeStartMs: startedAt });
|
||||
scheduledResumeTimer = window.setTimeout(() => {
|
||||
scheduledResumeTimer = null;
|
||||
set({ scheduledResumeAtMs: null });
|
||||
set({ scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
get().resume();
|
||||
}, delayMs) as unknown as number;
|
||||
},
|
||||
|
||||
+210
-38
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
|
||||
if (deadlineMs == null) return '';
|
||||
@@ -11,3 +15,39 @@ export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user