mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(components): co-locate feature-specific components (HostApprovalQueue->orbit, Playback{Delay,Schedule}->playback, LicensesPanel->settings)
This commit is contained in:
@@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import PlaybackDelayModal from '@/components/PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from '@/components/PlaybackScheduleBadge';
|
||||
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
|
||||
// Play/Pause button (isolated — subscribes to isPlaying only).
|
||||
export const FsPlayBtn = memo(function FsPlayBtn({
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, X, Inbox } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import {
|
||||
approveOrbitSuggestion,
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '@/features/orbit';
|
||||
import { TrackCoverArtImage } from '@/cover/TrackCoverArtImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '@/features/orbit';
|
||||
|
||||
const HOST_APPROVAL_COVER_CSS_PX = 36;
|
||||
|
||||
/**
|
||||
* Host-only approval strip. Renders directly below the OrbitQueueHead
|
||||
* when `autoApprove === false` and at least one guest suggestion is
|
||||
* waiting. Shows each pending track with Approve / Decline controls.
|
||||
*
|
||||
* Only rendered by the host-side render path (QueuePanel); guests never
|
||||
* see this section — they watch their own pending list.
|
||||
*/
|
||||
export default function HostApprovalQueue() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys);
|
||||
const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys);
|
||||
|
||||
const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS;
|
||||
const autoApproveOff = settings.autoApprove === false;
|
||||
|
||||
// Pending = everything in the session's suggestion history that isn't
|
||||
// host-authored, isn't already merged, and hasn't been declined.
|
||||
const pendingItems = useMemo(() => {
|
||||
if (!state) return [];
|
||||
const mergedSet = new Set(mergedKeys);
|
||||
const declinedSet = new Set(declinedKeys);
|
||||
return state.queue.filter(q =>
|
||||
q.addedBy !== state.host
|
||||
&& !mergedSet.has(suggestionKey(q))
|
||||
&& !declinedSet.has(suggestionKey(q))
|
||||
);
|
||||
}, [state, mergedKeys, declinedKeys]);
|
||||
|
||||
// Track-metadata cache (title/artist/cover) for the pending items.
|
||||
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
|
||||
const wantedKey = useMemo(
|
||||
() => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'),
|
||||
[pendingItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
const wanted = wantedKey ? wantedKey.split('|') : [];
|
||||
const missing = wanted.filter(id => id && !songs[id]);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
setSongs(prev => {
|
||||
const next = { ...prev };
|
||||
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="host-approval">
|
||||
<div className="host-approval__head">
|
||||
<Inbox size={12} />
|
||||
<span>{t('orbit.approvalTitle')}</span>
|
||||
<span className="host-approval__count">{pendingItems.length}</span>
|
||||
</div>
|
||||
<div className="host-approval__list">
|
||||
{pendingItems.map(q => {
|
||||
const song = songs[q.trackId];
|
||||
const key = suggestionKey(q);
|
||||
return (
|
||||
<div key={key} className="host-approval__item">
|
||||
{song ? (
|
||||
<TrackCoverArtImage
|
||||
song={song}
|
||||
libraryResolve
|
||||
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="host-approval__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="host-approval__cover host-approval__cover--ph" />
|
||||
)}
|
||||
<div className="host-approval__info">
|
||||
<div className="host-approval__title">{song?.title ?? '…'}</div>
|
||||
<div className="host-approval__artist">{song?.artist ?? ''}</div>
|
||||
<div className="host-approval__submitter">
|
||||
{t('orbit.approvalFrom', { user: q.addedBy })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="host-approval__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--approve"
|
||||
onClick={() => { void approveOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalAccept')}
|
||||
aria-label={t('orbit.approvalAccept')}
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--decline"
|
||||
onClick={() => { declineOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalDecline')}
|
||||
aria-label={t('orbit.approvalDecline')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Moon, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TFunction } from 'i18next';
|
||||
import { formatPlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import { formatClockTime } from '@/lib/format/formatClockTime';
|
||||
import {
|
||||
isValidPlaybackSchedulePreviewTimestamp,
|
||||
parsePlaybackDelayCustomMinutes,
|
||||
scheduleDelayMsFromSeconds,
|
||||
scheduleSecondsFromCustomMinutes,
|
||||
} from '@/features/playback/utils/playback/playbackScheduleDelay';
|
||||
|
||||
/** 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, i18n } = 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('');
|
||||
/** 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;
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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(() => {
|
||||
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 minutes = parsePlaybackDelayCustomMinutes(customMinutes);
|
||||
if (minutes == null) return null;
|
||||
return scheduleSecondsFromCustomMinutes(minutes);
|
||||
}, [customMinutes]);
|
||||
|
||||
const applyPause = (sec: number) => {
|
||||
schedulePauseIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const applyStart = (sec: number) => {
|
||||
scheduleResumeIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const useAnchor = !!anchorRef;
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
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');
|
||||
|
||||
// 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 clockFormat = useAuthStore(s => s.clockFormat);
|
||||
const previewSeconds = hoverSeconds ?? customSeconds;
|
||||
const previewAtMs =
|
||||
previewSeconds != null
|
||||
? nowTick + scheduleDelayMsFromSeconds(previewSeconds)
|
||||
: null;
|
||||
const previewClock =
|
||||
previewAtMs != null && isValidPlaybackSchedulePreviewTimestamp(previewAtMs)
|
||||
? formatClockTime(previewAtMs, clockFormat, i18n.language)
|
||||
: 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' : ''}`}
|
||||
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 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>
|
||||
|
||||
<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) && (
|
||||
<>
|
||||
{scheduledAt != null && (
|
||||
<div className="playback-delay-section__head playback-delay-section__head--tight">
|
||||
<span className="playback-delay-section__countdown">
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)}
|
||||
</span>
|
||||
{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"
|
||||
onMouseLeave={() => setHoverSeconds(null)}
|
||||
>
|
||||
{PRESET_SECONDS.map(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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatPlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility';
|
||||
|
||||
export interface PlaybackScheduleBadgeProps {
|
||||
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
|
||||
layoutAnchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Extra class on the portaled ring (e.g. fullscreen sizing). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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 [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
|
||||
const windowHidden = useWindowVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNowMs(Date.now());
|
||||
}, [deadlineMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null || windowHidden) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (document.hidden || window.__psyHidden) return;
|
||||
setNowMs(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [deadlineMs, windowHidden]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (deadlineMs == null || windowHidden) return;
|
||||
const el = layoutAnchorRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setAnchorRect({
|
||||
left: r.left + r.width / 2,
|
||||
top: r.top + r.height / 2,
|
||||
size: Math.max(r.width, r.height),
|
||||
});
|
||||
};
|
||||
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, windowHidden]);
|
||||
|
||||
if (deadlineMs == null || startMs == null || !anchorRect) return null;
|
||||
|
||||
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 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={[
|
||||
'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,
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { useRadioMetadata } from '@/features/radio';
|
||||
import { useRadioMprisSync } from '@/features/radio';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from '@/components/PlaybackDelayModal';
|
||||
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { TFunction } from 'i18next';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAutodjTransitionUi } from '@/features/playback/store/autodjTransitionUi';
|
||||
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import PlaybackScheduleBadge from '@/components/PlaybackScheduleBadge';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useRef, useMemo } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { OrbitGuestQueue, OrbitQueueHead } from '@/features/orbit';
|
||||
import HostApprovalQueue from '@/components/HostApprovalQueue';
|
||||
import HostApprovalQueue from '@/features/orbit/components/HostApprovalQueue';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Search, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open as openExternal } from '@tauri-apps/plugin-shell';
|
||||
import {
|
||||
HIGHLIGHTED_DEPENDENCIES,
|
||||
loadLicensesData,
|
||||
type LicenseEntry,
|
||||
type LicensesData,
|
||||
} from '@/utils/licensesData';
|
||||
import LicenseTextModal from '@/components/LicenseTextModal';
|
||||
|
||||
const ROW_HEIGHT = 56;
|
||||
|
||||
function formatDate(iso: string, locale: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(e: LicenseEntry): string {
|
||||
return `${e.source}:${e.name}@${e.version}`;
|
||||
}
|
||||
|
||||
function LicenseRow({
|
||||
entry,
|
||||
onSelect,
|
||||
onOpenRepo,
|
||||
}: {
|
||||
entry: LicenseEntry;
|
||||
onSelect: (e: LicenseEntry) => void;
|
||||
onOpenRepo: (url: string) => void;
|
||||
}) {
|
||||
const repo = entry.repository || entry.homepage;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(entry)}
|
||||
className="licenses-row"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textAlign: 'left',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '6px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>{entry.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem', fontWeight: 400 }}>
|
||||
{entry.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
fontSize: '0.72rem',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>{entry.source}</span>
|
||||
<span>·</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{entry.licenses.join(', ') || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenRepo(repo);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenRepo(repo);
|
||||
}
|
||||
}}
|
||||
aria-label="Open repository"
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
padding: '6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
display: 'inline-flex',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LicensesPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [data, setData] = useState<LicensesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<LicenseEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLicensesData()
|
||||
.then((d) => {
|
||||
if (!cancelled) setData(d);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e?.message ?? e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const highlights = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const byKey = new Map<string, LicenseEntry>();
|
||||
for (const e of data.entries) {
|
||||
byKey.set(`${e.source}:${e.name}`, e);
|
||||
}
|
||||
return HIGHLIGHTED_DEPENDENCIES.map((h) => byKey.get(`${h.source}:${h.name}`)).filter(
|
||||
(e): e is LicenseEntry => e != null,
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return data.entries;
|
||||
return data.entries.filter((e) => {
|
||||
if (e.name.toLowerCase().includes(q)) return true;
|
||||
if (e.version.toLowerCase().includes(q)) return true;
|
||||
for (const lid of e.licenses) {
|
||||
if (lid.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
if (e.source.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
const openRepo = (url: string) => {
|
||||
openExternal(url).catch(() => {});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.loadError')} <span style={{ color: 'var(--danger)' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.82rem', lineHeight: 1.55 }}>
|
||||
{t('licenses.intro')}
|
||||
</div>
|
||||
|
||||
{/* Highlight block */}
|
||||
{highlights.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
{t('licenses.highlights')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{highlights.map((e) => (
|
||||
<LicenseRow key={entryKey(e)} entry={e} onSelect={setSelected} onOpenRepo={openRepo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('licenses.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '2px 0',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Virtual list */}
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
style={{
|
||||
height: '420px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--bg-secondary, var(--bg))',
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '14px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const entry = filtered[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
height: vi.size,
|
||||
}}
|
||||
>
|
||||
<LicenseRow entry={entry} onSelect={setSelected} onOpenRepo={openRepo} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t('licenses.totalLine', {
|
||||
total: data.stats.total,
|
||||
cargo: data.stats.cargo,
|
||||
npm: data.stats.npm,
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t('licenses.generatedAt', { date: formatDate(data.generatedAt, i18n.language) })}</span>
|
||||
</div>
|
||||
|
||||
<LicenseTextModal entry={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { IS_LINUX } from '@/lib/util/platform';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { AboutPsysonicBrandHeader } from '@/components/AboutPsysonicLol';
|
||||
import CustomSelect from '@/ui/CustomSelect';
|
||||
import LicensesPanel from '@/components/LicensesPanel';
|
||||
import LicensesPanel from '@/features/settings/components/LicensesPanel';
|
||||
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
|
||||
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
|
||||
Reference in New Issue
Block a user