diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index ef016995..4d30cbee 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -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(null); + const scheduleRemaining = usePlaybackScheduleRemaining(); return ( <> @@ -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 ? : } + {scheduleRemaining != null ? ( + + {scheduleRemaining.mode === 'pause' + ? + : } + {scheduleRemaining.remaining} + + ) : isPlaying ? : } setDelayModalOpen(false)} anchorRef={controlsAnchorRef} /> diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 64ce1ee0..f928a60d 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -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(null); const playSlotRef = useRef(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 ? : } + {scheduleRemaining != null ? ( + + {scheduleRemaining.mode === 'pause' + ? + : } + {scheduleRemaining.remaining} + + ) : isPlaying ? : } -

- {heading} -

- {canPauseLater && ( +
+ {HeadingIcon && ( + + )} +

+ {heading} +

+
+ + {(canPauseLater || canStartLater) && ( <> - {scheduledPauseAtMs != null && ( + {scheduledAt != null && (
- {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, nowTick)} + {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)} - + {clearScheduled && ( + + )}
)} -
+
setHoverSeconds(null)} + > {PRESET_SECONDS.map(sec => ( - ))}
- - )} - {canStartLater && ( - <> - {scheduledResumeAtMs != null && ( -
- - {t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledResumeAtMs, nowTick)} - - +
+
+ setCustomMinutes(e.target.value)} + aria-label={t('player.delayCustomMinutes')} + /> +
- )} -
- {PRESET_SECONDS.map(sec => ( - - ))} + +
+ +
+ {previewClock != null && ( + <> + + {canPauseLater ? t('player.delayPreviewPause') : t('player.delayPreviewStart')} + + {previewClock} + + )}
)} @@ -223,34 +292,6 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac

{t('player.delayInactiveStart')}

)} - - {(canPauseLater || canStartLater) && ( -
- setCustomMinutes(e.target.value)} - aria-label={t('player.delayCustomMinutes')} - /> - -
- )}
, document.body, diff --git a/src/components/PlaybackScheduleBadge.tsx b/src/components/PlaybackScheduleBadge.tsx index 6f088af7..ed1e45fe 100644 --- a/src/components/PlaybackScheduleBadge.tsx +++ b/src/components/PlaybackScheduleBadge.tsx @@ -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; - /** 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({ 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( - - {text} + + , document.body, ); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index eb746a85..a1e097bd 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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(null); const playSlotRef = useRef(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 ? : } + {scheduleRemaining != null ? ( + + {scheduleRemaining.mode === 'pause' + ? + : } + {scheduleRemaining.remaining} + + ) : isPlaying ? : }