mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(player): sleep timer and delayed start via long-press on play/pause (#270)
Add scheduled pause and resume timers in the player store, cleared on stop and track changes. Long-press opens a compact preset modal anchored above the transport row; one-tap presets plus custom minutes. Portaled countdown badge on the play button; clear the long-press click guard when the modal closes so the first play/pause click works after scheduling.
This commit is contained in:
@@ -14,6 +14,9 @@ import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -582,14 +585,33 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, tr
|
||||
});
|
||||
|
||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const FsPlayBtn = memo(function FsPlayBtn({
|
||||
controlsAnchorRef,
|
||||
}: {
|
||||
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
</button>
|
||||
<>
|
||||
<span ref={playSlotRef} className="playback-transport-play-wrap">
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
|
||||
<button
|
||||
type="button"
|
||||
className="fs-btn fs-btn-play"
|
||||
{...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" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -716,6 +738,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
@@ -822,14 +845,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls">
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||||
@@ -162,6 +165,9 @@ export default function MobilePlayerView() {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
@@ -362,7 +368,7 @@ export default function MobilePlayerView() {
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="mp-controls">
|
||||
<div className="mp-controls" ref={transportAnchorRef}>
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-sm"
|
||||
onClick={() => shuffleQueue()}
|
||||
@@ -373,9 +379,18 @@ export default function MobilePlayerView() {
|
||||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-play"
|
||||
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" />}
|
||||
</button>
|
||||
</span>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
@@ -406,6 +421,8 @@ export default function MobilePlayerView() {
|
||||
|
||||
{/* Lyrics Drawer */}
|
||||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||||
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TFunction } from 'i18next';
|
||||
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
/** One tap = schedule; custom minutes still covers any duration. */
|
||||
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
|
||||
|
||||
function formatPresetLabel(seconds: number, t: TFunction): string {
|
||||
if (seconds < 60) return t('player.delayFmtSec', { n: seconds });
|
||||
if (seconds < 3600) return t('player.delayFmtMin', { n: seconds / 60 });
|
||||
return t('player.delayFmtHr', { n: seconds / 3600 });
|
||||
}
|
||||
|
||||
function computeAnchoredPanelStyle(anchorEl: HTMLElement): React.CSSProperties {
|
||||
const ar = anchorEl.getBoundingClientRect();
|
||||
const mw = Math.min(360, Math.max(200, window.innerWidth - 32));
|
||||
let left = ar.left + ar.width / 2 - mw / 2;
|
||||
const pad = 12;
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - mw - pad));
|
||||
const gap = 10;
|
||||
return {
|
||||
position: 'fixed',
|
||||
left,
|
||||
bottom: window.innerHeight - ar.top + gap,
|
||||
width: mw,
|
||||
maxWidth: 360,
|
||||
margin: 0,
|
||||
maxHeight: 'min(72vh, calc(100vh - 24px))',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaybackDelayModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** When set, panel is fixed just above this element (transport strip). */
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function PlaybackDelayModal({ open, onClose, anchorRef }: PlaybackDelayModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isPlaying,
|
||||
currentTrack,
|
||||
currentRadio,
|
||||
scheduledPauseAtMs,
|
||||
scheduledResumeAtMs,
|
||||
schedulePauseIn,
|
||||
scheduleResumeIn,
|
||||
clearScheduledPause,
|
||||
clearScheduledResume,
|
||||
} = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
isPlaying: s.isPlaying,
|
||||
currentTrack: s.currentTrack,
|
||||
currentRadio: s.currentRadio,
|
||||
scheduledPauseAtMs: s.scheduledPauseAtMs,
|
||||
scheduledResumeAtMs: s.scheduledResumeAtMs,
|
||||
schedulePauseIn: s.schedulePauseIn,
|
||||
scheduleResumeIn: s.scheduleResumeIn,
|
||||
clearScheduledPause: s.clearScheduledPause,
|
||||
clearScheduledResume: s.clearScheduledResume,
|
||||
})),
|
||||
);
|
||||
|
||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||
const [posTick, setPosTick] = useState(0);
|
||||
const [customMinutes, setCustomMinutes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCustomMinutes('');
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (scheduledPauseAtMs == null && scheduledResumeAtMs == null) return;
|
||||
const id = window.setInterval(() => setNowTick(Date.now()), 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open, scheduledPauseAtMs, scheduledResumeAtMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !anchorRef) return;
|
||||
const bump = () => setPosTick(x => x + 1);
|
||||
window.addEventListener('resize', bump);
|
||||
window.addEventListener('scroll', bump, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', bump);
|
||||
window.removeEventListener('scroll', bump, true);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const canPauseLater = isPlaying && (!!currentTrack || !!currentRadio);
|
||||
const canStartLater = !isPlaying && (!!currentTrack || !!currentRadio);
|
||||
|
||||
const customSeconds = useMemo(() => {
|
||||
const n = parseFloat(customMinutes.replace(',', '.'));
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.round(n * 60);
|
||||
}, [customMinutes]);
|
||||
|
||||
const applyPause = (sec: number) => {
|
||||
schedulePauseIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const applyStart = (sec: number) => {
|
||||
scheduleResumeIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const useAnchor = !!anchorRef;
|
||||
const anchorEl = anchorRef?.current ?? null;
|
||||
void posTick;
|
||||
const anchoredPanelStyle =
|
||||
open && useAnchor && anchorEl ? computeAnchoredPanelStyle(anchorEl) : undefined;
|
||||
|
||||
const heading =
|
||||
canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
|
||||
const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="playback-delay-modal-title"
|
||||
style={
|
||||
useAnchor
|
||||
? { alignItems: 'stretch', justifyContent: 'flex-start', padding: 0 }
|
||||
: { alignItems: 'center', paddingTop: 0 }
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="modal-content playback-delay-modal"
|
||||
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 && (
|
||||
<>
|
||||
{scheduledPauseAtMs != null && (
|
||||
<div className="playback-delay-section__head playback-delay-section__head--tight">
|
||||
<span className="playback-delay-section__countdown">
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledPauseAtMs, 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>
|
||||
</div>
|
||||
)}
|
||||
<div className="playback-delay-chips playback-delay-chips--compact">
|
||||
{PRESET_SECONDS.map(sec => (
|
||||
<button key={`p-${sec}`} type="button" className="playback-delay-chip" onClick={() => applyPause(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>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canPauseLater && !canStartLater && (
|
||||
<div className="playback-delay-idle">
|
||||
<p className="playback-delay-muted">{t('player.delayInactivePause')}</p>
|
||||
<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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
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). */
|
||||
layoutAnchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Extra classes on the portaled pill (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.
|
||||
*/
|
||||
export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
isPlaying: s.isPlaying,
|
||||
scheduledPauseAtMs: s.scheduledPauseAtMs,
|
||||
scheduledResumeAtMs: s.scheduledResumeAtMs,
|
||||
})),
|
||||
);
|
||||
|
||||
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [deadlineMs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
const el = layoutAnchorRef.current;
|
||||
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',
|
||||
});
|
||||
};
|
||||
sync();
|
||||
window.addEventListener('resize', sync);
|
||||
window.addEventListener('scroll', sync, true);
|
||||
const iv = window.setInterval(sync, 400);
|
||||
return () => {
|
||||
window.removeEventListener('resize', sync);
|
||||
window.removeEventListener('scroll', sync, true);
|
||||
window.clearInterval(iv);
|
||||
};
|
||||
}, [deadlineMs, layoutAnchorRef]);
|
||||
|
||||
if (deadlineMs == null) 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 pillClass = ['playback-schedule-badge', 'playback-schedule-badge--floating', className].filter(Boolean).join(' ');
|
||||
|
||||
return createPortal(
|
||||
<span className={pillClass} style={panelStyle} aria-live="polite" title={label}>
|
||||
{text}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -136,6 +139,10 @@ export default function PlayerBar() {
|
||||
};
|
||||
}, [floatingPlayerBar]);
|
||||
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||
@@ -185,6 +192,7 @@ export default function PlayerBar() {
|
||||
};
|
||||
|
||||
const playerBarContent = (
|
||||
<>
|
||||
<footer
|
||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
|
||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||
@@ -292,21 +300,26 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="player-buttons">
|
||||
<div className="player-buttons" ref={transportAnchorRef}>
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
type="button"
|
||||
{...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" />}
|
||||
</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}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
@@ -448,6 +461,8 @@ export default function PlayerBar() {
|
||||
)}
|
||||
|
||||
</footer>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (floatingPlayerBar) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LONG_PRESS_MS = 550;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
|
||||
/**
|
||||
* Long-press on play/pause opens the delay modal; short click toggles playback.
|
||||
*/
|
||||
export function usePlaybackDelayPress(togglePlay: () => void) {
|
||||
const [delayModalOpen, setDelayModalOpen] = useState(false);
|
||||
const ignoreNextClickRef = useRef(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Long-press sets ignoreNextClickRef so the synthetic click after opening does not toggle play.
|
||||
// Closing the modal from chips / Apply / overlay never hits this button's onClick, so clear the flag here.
|
||||
useEffect(() => {
|
||||
if (!delayModalOpen) ignoreNextClickRef.current = false;
|
||||
}, [delayModalOpen]);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearTimer();
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
ignoreNextClickRef.current = true;
|
||||
setDelayModalOpen(true);
|
||||
}, LONG_PRESS_MS) as unknown as number;
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timerRef.current == null) return;
|
||||
if (
|
||||
Math.hypot(e.clientX - startRef.current.x, e.clientY - startRef.current.y) > MOVE_CANCEL_PX
|
||||
) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const endPointer = useCallback(() => {
|
||||
clearTimer();
|
||||
}, [clearTimer]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (ignoreNextClickRef.current) {
|
||||
ignoreNextClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
togglePlay();
|
||||
},
|
||||
[togglePlay],
|
||||
);
|
||||
|
||||
const playPauseBind = {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: endPointer,
|
||||
onPointerLeave: endPointer,
|
||||
onPointerCancel: endPointer,
|
||||
onClick,
|
||||
};
|
||||
|
||||
return { delayModalOpen, setDelayModalOpen, playPauseBind };
|
||||
}
|
||||
@@ -1126,6 +1126,24 @@ export const deTranslation = {
|
||||
prev: 'Vorheriger Titel',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pause nach',
|
||||
delayStartSection: 'Start nach',
|
||||
delaySchedulePause: 'Pause planen',
|
||||
delayScheduleStart: 'Start planen',
|
||||
delayCancelPause: 'Pause-Timer abbrechen',
|
||||
delayCancelStart: 'Start-Timer abbrechen',
|
||||
delayInactivePause: 'Nur während der Wiedergabe verfügbar.',
|
||||
delayInactiveStart: 'Nur in Pause mit geladenem Titel oder Radio.',
|
||||
delayCustomMinutes: 'Eigene Dauer (Minuten)',
|
||||
delayCustomPlaceholder: 'z. B. 2,5',
|
||||
closeDelayModal: 'Schließen',
|
||||
delayIn: 'in',
|
||||
delayFmtSec: '{{n}} s',
|
||||
delayFmtMin: '{{n}} Min',
|
||||
delayFmtHr: '{{n}} Std',
|
||||
delayCancel: 'Abbrechen',
|
||||
delayApply: 'Übernehmen',
|
||||
next: 'Nächster Titel',
|
||||
repeat: 'Wiederholen',
|
||||
repeatOff: 'Aus',
|
||||
|
||||
@@ -1128,6 +1128,24 @@ export const enTranslation = {
|
||||
prev: 'Previous Track',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pause after',
|
||||
delayStartSection: 'Start after',
|
||||
delaySchedulePause: 'Schedule pause',
|
||||
delayScheduleStart: 'Schedule start',
|
||||
delayCancelPause: 'Cancel pause timer',
|
||||
delayCancelStart: 'Cancel start timer',
|
||||
delayInactivePause: 'Available only while something is playing.',
|
||||
delayInactiveStart: 'Available when paused with a track or radio loaded.',
|
||||
delayCustomMinutes: 'Custom delay (minutes)',
|
||||
delayCustomPlaceholder: 'e.g. 2.5',
|
||||
closeDelayModal: 'Close',
|
||||
delayIn: 'in',
|
||||
delayFmtSec: '{{n}}s',
|
||||
delayFmtMin: '{{n}} min',
|
||||
delayFmtHr: '{{n}} h',
|
||||
delayCancel: 'Cancel',
|
||||
delayApply: 'Apply',
|
||||
next: 'Next Track',
|
||||
repeat: 'Repeat',
|
||||
repeatOff: 'Off',
|
||||
|
||||
@@ -1119,6 +1119,24 @@ export const esTranslation = {
|
||||
prev: 'Pista Anterior',
|
||||
play: 'Reproducir',
|
||||
pause: 'Pausa',
|
||||
delayModalTitle: 'Temporizador',
|
||||
delayPauseSection: 'Pausar después de',
|
||||
delayStartSection: 'Iniciar después de',
|
||||
delaySchedulePause: 'Programar pausa',
|
||||
delayScheduleStart: 'Programar inicio',
|
||||
delayCancelPause: 'Cancelar temporizador de pausa',
|
||||
delayCancelStart: 'Cancelar temporizador de inicio',
|
||||
delayInactivePause: 'Solo disponible durante la reproducción.',
|
||||
delayInactiveStart: 'Solo en pausa con pista o radio cargada.',
|
||||
delayCustomMinutes: 'Retraso personalizado (minutos)',
|
||||
delayCustomPlaceholder: 'p. ej. 2,5',
|
||||
closeDelayModal: 'Cerrar',
|
||||
delayIn: 'en',
|
||||
delayFmtSec: '{{n}} s',
|
||||
delayFmtMin: '{{n}} min',
|
||||
delayFmtHr: '{{n}} h',
|
||||
delayCancel: 'Cancelar',
|
||||
delayApply: 'Aplicar',
|
||||
next: 'Pista Siguiente',
|
||||
repeat: 'Repetir',
|
||||
repeatOff: 'Apagado',
|
||||
|
||||
@@ -1114,6 +1114,24 @@ export const frTranslation = {
|
||||
prev: 'Piste précédente',
|
||||
play: 'Lecture',
|
||||
pause: 'Pause',
|
||||
delayModalTitle: 'Minuteur',
|
||||
delayPauseSection: 'Pause après',
|
||||
delayStartSection: 'Démarrage après',
|
||||
delaySchedulePause: 'Programmer la pause',
|
||||
delayScheduleStart: 'Programmer le démarrage',
|
||||
delayCancelPause: 'Annuler le minuteur de pause',
|
||||
delayCancelStart: 'Annuler le minuteur de démarrage',
|
||||
delayInactivePause: 'Disponible uniquement pendant la lecture.',
|
||||
delayInactiveStart: 'Disponible en pause avec une piste ou une radio chargée.',
|
||||
delayCustomMinutes: 'Délai personnalisé (minutes)',
|
||||
delayCustomPlaceholder: 'ex. 2,5',
|
||||
closeDelayModal: 'Fermer',
|
||||
delayIn: 'dans',
|
||||
delayFmtSec: '{{n}} s',
|
||||
delayFmtMin: '{{n}} min',
|
||||
delayFmtHr: '{{n}} h',
|
||||
delayCancel: 'Annuler',
|
||||
delayApply: 'Appliquer',
|
||||
next: 'Piste suivante',
|
||||
repeat: 'Répéter',
|
||||
repeatOff: 'Désactivé',
|
||||
|
||||
@@ -1113,6 +1113,24 @@ export const nbTranslation = {
|
||||
prev: 'Forrige spor',
|
||||
play: 'Spill av',
|
||||
pause: 'Pause',
|
||||
delayModalTitle: 'Tidsur',
|
||||
delayPauseSection: 'Pause etter',
|
||||
delayStartSection: 'Start etter',
|
||||
delaySchedulePause: 'Planlegg pause',
|
||||
delayScheduleStart: 'Planlegg start',
|
||||
delayCancelPause: 'Avbryt pause-timer',
|
||||
delayCancelStart: 'Avbryt start-timer',
|
||||
delayInactivePause: 'Kun tilgjengelig under avspilling.',
|
||||
delayInactiveStart: 'Kun i pause med spor eller radio lastet.',
|
||||
delayCustomMinutes: 'Egen forsinkelse (minutter)',
|
||||
delayCustomPlaceholder: 'f.eks. 2,5',
|
||||
closeDelayModal: 'Lukk',
|
||||
delayIn: 'om',
|
||||
delayFmtSec: '{{n}} s',
|
||||
delayFmtMin: '{{n}} min',
|
||||
delayFmtHr: '{{n}} t',
|
||||
delayCancel: 'Avbryt',
|
||||
delayApply: 'Bruk',
|
||||
next: 'Neste spor',
|
||||
repeat: 'Gjenta',
|
||||
repeatOff: 'Av',
|
||||
|
||||
@@ -1113,6 +1113,24 @@ export const nlTranslation = {
|
||||
prev: 'Vorig nummer',
|
||||
play: 'Afspelen',
|
||||
pause: 'Pauzeren',
|
||||
delayModalTitle: 'Timer',
|
||||
delayPauseSection: 'Pauzeren na',
|
||||
delayStartSection: 'Starten na',
|
||||
delaySchedulePause: 'Pauze plannen',
|
||||
delayScheduleStart: 'Start plannen',
|
||||
delayCancelPause: 'Pauze-timer annuleren',
|
||||
delayCancelStart: 'Start-timer annuleren',
|
||||
delayInactivePause: 'Alleen beschikbaar tijdens afspelen.',
|
||||
delayInactiveStart: 'Alleen in pauze met een geladen nummer of radio.',
|
||||
delayCustomMinutes: 'Aangepaste vertraging (minuten)',
|
||||
delayCustomPlaceholder: 'bijv. 2,5',
|
||||
closeDelayModal: 'Sluiten',
|
||||
delayIn: 'over',
|
||||
delayFmtSec: '{{n}} s',
|
||||
delayFmtMin: '{{n}} min',
|
||||
delayFmtHr: '{{n}} u',
|
||||
delayCancel: 'Annuleren',
|
||||
delayApply: 'Toepassen',
|
||||
next: 'Volgend nummer',
|
||||
repeat: 'Herhalen',
|
||||
repeatOff: 'Uit',
|
||||
|
||||
@@ -1191,6 +1191,24 @@ export const ruTranslation = {
|
||||
prev: 'Предыдущий трек',
|
||||
play: 'Играть',
|
||||
pause: 'Пауза',
|
||||
delayModalTitle: 'Таймер',
|
||||
delayPauseSection: 'Пауза через',
|
||||
delayStartSection: 'Старт через',
|
||||
delaySchedulePause: 'Запланировать паузу',
|
||||
delayScheduleStart: 'Запланировать старт',
|
||||
delayCancelPause: 'Отменить таймер паузы',
|
||||
delayCancelStart: 'Отменить таймер старта',
|
||||
delayInactivePause: 'Доступно только во время воспроизведения.',
|
||||
delayInactiveStart: 'Доступно на паузе при загруженном треке или радио.',
|
||||
delayCustomMinutes: 'Своя задержка (минуты)',
|
||||
delayCustomPlaceholder: 'напр. 2.5',
|
||||
closeDelayModal: 'Закрыть',
|
||||
delayIn: 'через',
|
||||
delayFmtSec: '{{n}} с',
|
||||
delayFmtMin: '{{n}} мин',
|
||||
delayFmtHr: '{{n}} ч',
|
||||
delayCancel: 'Отмена',
|
||||
delayApply: 'Применить',
|
||||
next: 'Следующий трек',
|
||||
repeat: 'Повтор',
|
||||
repeatOff: 'Выкл.',
|
||||
|
||||
@@ -1108,6 +1108,24 @@ export const zhTranslation = {
|
||||
prev: '上一首',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
delayModalTitle: '定时',
|
||||
delayPauseSection: '多久后暂停',
|
||||
delayStartSection: '多久后开始',
|
||||
delaySchedulePause: '安排暂停',
|
||||
delayScheduleStart: '安排开始',
|
||||
delayCancelPause: '取消暂停定时',
|
||||
delayCancelStart: '取消开始定时',
|
||||
delayInactivePause: '仅在正在播放时可用。',
|
||||
delayInactiveStart: '仅在暂停且已加载曲目或电台时可用。',
|
||||
delayCustomMinutes: '自定义延迟(分钟)',
|
||||
delayCustomPlaceholder: '例如 2.5',
|
||||
closeDelayModal: '关闭',
|
||||
delayIn: '还有',
|
||||
delayFmtSec: '{{n}} 秒',
|
||||
delayFmtMin: '{{n}} 分钟',
|
||||
delayFmtHr: '{{n}} 小时',
|
||||
delayCancel: '取消',
|
||||
delayApply: '应用',
|
||||
next: '下一首',
|
||||
repeat: '重复',
|
||||
repeatOff: '关闭',
|
||||
|
||||
@@ -175,6 +175,14 @@ interface PlayerState {
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
togglePlay: () => void;
|
||||
/** Wall-clock ms when auto-pause fires, or null. */
|
||||
scheduledPauseAtMs: number | null;
|
||||
/** Wall-clock ms when auto-resume fires, or null. */
|
||||
scheduledResumeAtMs: number | null;
|
||||
schedulePauseIn: (seconds: number) => void;
|
||||
scheduleResumeIn: (seconds: number) => void;
|
||||
clearScheduledPause: () => void;
|
||||
clearScheduledResume: () => void;
|
||||
next: (manual?: boolean) => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
@@ -264,6 +272,29 @@ const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
|
||||
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
|
||||
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
|
||||
|
||||
/** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */
|
||||
let scheduledPauseTimer: number | null = null;
|
||||
let scheduledResumeTimer: number | null = null;
|
||||
|
||||
function clearScheduledPauseTimers() {
|
||||
if (scheduledPauseTimer != null) {
|
||||
window.clearTimeout(scheduledPauseTimer);
|
||||
scheduledPauseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearScheduledResumeTimers() {
|
||||
if (scheduledResumeTimer != null) {
|
||||
window.clearTimeout(scheduledResumeTimer);
|
||||
scheduledResumeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllPlaybackScheduleTimers() {
|
||||
clearScheduledPauseTimers();
|
||||
clearScheduledResumeTimers();
|
||||
}
|
||||
|
||||
function setSeekTarget(seconds: number) {
|
||||
seekTarget = seconds;
|
||||
seekTargetSetAt = Date.now();
|
||||
@@ -1019,6 +1050,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}),
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
scheduledPauseAtMs: null,
|
||||
scheduledResumeAtMs: null,
|
||||
repeatMode: 'off',
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
|
||||
@@ -1093,6 +1126,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── stop ────────────────────────────────────────────────────────────────
|
||||
stop: () => {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
if (get().currentRadio) {
|
||||
clearRadioReconnectTimer();
|
||||
radioStopping = true;
|
||||
@@ -1112,6 +1146,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
scheduledPauseAtMs: null,
|
||||
scheduledResumeAtMs: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1119,6 +1155,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
playRadio: async (station) => {
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
isAudioPaused = false;
|
||||
clearRadioReconnectTimer();
|
||||
radioReconnectCount = 0;
|
||||
@@ -1163,6 +1201,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
return;
|
||||
}
|
||||
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||
@@ -1315,13 +1356,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
if (get().currentRadio) {
|
||||
radioAudio.pause();
|
||||
} else {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
}
|
||||
set({ isPlaying: false });
|
||||
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
},
|
||||
|
||||
resetAudioPause: () => {
|
||||
@@ -1329,6 +1371,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledResumeAtMs: null });
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
set({ isPlaying: true });
|
||||
@@ -1421,6 +1465,45 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
},
|
||||
|
||||
clearScheduledPause: () => {
|
||||
clearScheduledPauseTimers();
|
||||
set({ scheduledPauseAtMs: null });
|
||||
},
|
||||
|
||||
clearScheduledResume: () => {
|
||||
clearScheduledResumeTimers();
|
||||
set({ scheduledResumeAtMs: null });
|
||||
},
|
||||
|
||||
schedulePauseIn: (seconds) => {
|
||||
const s = get();
|
||||
if (!s.isPlaying) return;
|
||||
clearScheduledPauseTimers();
|
||||
const delayMs = Math.max(500, Math.round(Number(seconds) * 1000));
|
||||
const at = Date.now() + delayMs;
|
||||
set({ scheduledPauseAtMs: at });
|
||||
scheduledPauseTimer = window.setTimeout(() => {
|
||||
scheduledPauseTimer = null;
|
||||
set({ scheduledPauseAtMs: null });
|
||||
get().pause();
|
||||
}, delayMs) as unknown as number;
|
||||
},
|
||||
|
||||
scheduleResumeIn: (seconds) => {
|
||||
const s = get();
|
||||
if (s.isPlaying) return;
|
||||
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 });
|
||||
scheduledResumeTimer = window.setTimeout(() => {
|
||||
scheduledResumeTimer = null;
|
||||
set({ scheduledResumeAtMs: null });
|
||||
get().resume();
|
||||
}, delayMs) as unknown as number;
|
||||
},
|
||||
|
||||
togglePlay: () => {
|
||||
if (togglePlayLock) return;
|
||||
togglePlayLock = true;
|
||||
|
||||
@@ -2048,6 +2048,213 @@
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* ─ Playback delay (sleep / delayed start) modal ─ */
|
||||
.playback-delay-modal {
|
||||
max-height: min(85vh, 560px);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.playback-delay-modal__title {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding-right: 2rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playback-delay-modal-overlay--anchored {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.playback-transport-play-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.playback-schedule-badge--floating {
|
||||
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-section__head--tight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playback-delay-inline-cancel {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.playback-delay-chips--compact {
|
||||
margin-bottom: var(--space-4);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.playback-delay-custom--inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.playback-delay-custom--inline .playback-delay-custom__input {
|
||||
flex: 1 1 100px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playback-delay-idle {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.playback-delay-idle .playback-delay-muted:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.playback-delay-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
background: color-mix(in srgb, var(--ctp-base) 40%, transparent);
|
||||
}
|
||||
|
||||
.playback-delay-section--disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playback-delay-section__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.playback-delay-section__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.playback-delay-section__countdown {
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playback-delay-muted {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.playback-delay-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.playback-delay-chip {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms 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));
|
||||
}
|
||||
|
||||
.playback-delay-chip--on {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--ctp-surface1));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.playback-delay-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playback-delay-custom__label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playback-delay-custom__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playback-delay-custom__input {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--ctp-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playback-delay-custom__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─ Playlist edit modal ─ */
|
||||
.playlist-edit-modal {
|
||||
max-width: 560px;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 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 '';
|
||||
const sec = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1000));
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m >= 60) {
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}:${rm.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user