mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(search): queue preview on Ctrl+V paste and modal polish
Global queue paste opens the preview modal before play; overlay scrollbar, context-menu suppression, changelog/credits for PR #716 and DanielWTE (#551).
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
|
||||
import { decodeSharePayloadFromText } from '../utils/share/shareLink';
|
||||
import { decodeServerMagicStringFromText } from '../utils/server/serverMagicString';
|
||||
import { applySharePastePayload } from '../utils/share/applySharePaste';
|
||||
import { applySharePastePayload, applySharePasteQueue } from '../utils/share/applySharePaste';
|
||||
import { shareQueueServerContext } from '../utils/share/shareServerOriginLabel';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareQueuePreview } from '../hooks/useShareQueuePreview';
|
||||
import ShareQueuePreviewModal from './search/ShareQueuePreviewModal';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
joinOrbitSession,
|
||||
@@ -30,13 +34,27 @@ const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||
*/
|
||||
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||
|
||||
export default function PasteClipboardHandler() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const busy = useRef(false);
|
||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
||||
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
|
||||
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
|
||||
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
|
||||
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
|
||||
() =>
|
||||
queuePaste
|
||||
? shareQueueServerContext(queuePaste.srv, servers, activeServerId)
|
||||
: { label: null, coverServer: null },
|
||||
[queuePaste, servers, activeServerId],
|
||||
);
|
||||
|
||||
// `not-found` and `ended` collapse into a single "link no longer valid"
|
||||
// dialog — from the guest's POV both mean the same thing: the invite
|
||||
@@ -137,13 +155,21 @@ export default function PasteClipboardHandler() {
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return;
|
||||
}
|
||||
if (busy.current) {
|
||||
if (busy.current || queuePaste) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (share.k === 'queue') {
|
||||
if (share.ids.length === 0) {
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
setQueuePaste(share);
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
void applySharePastePayload(share, navigate, t).finally(() => {
|
||||
busy.current = false;
|
||||
@@ -171,10 +197,37 @@ export default function PasteClipboardHandler() {
|
||||
};
|
||||
document.addEventListener('paste', onPaste, true);
|
||||
return () => document.removeEventListener('paste', onPaste, true);
|
||||
}, [navigate, t, isLoggedIn]);
|
||||
}, [navigate, t, isLoggedIn, queuePaste]);
|
||||
|
||||
const closeQueuePaste = () => {
|
||||
if (queuePasteBusy) return;
|
||||
setQueuePaste(null);
|
||||
};
|
||||
|
||||
const confirmQueuePaste = async () => {
|
||||
if (!queuePaste || queuePasteBusy) return;
|
||||
setQueuePasteBusy(true);
|
||||
const ok = await applySharePasteQueue(queuePaste, t);
|
||||
setQueuePasteBusy(false);
|
||||
if (ok) setQueuePaste(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{queuePaste && (
|
||||
<ShareQueuePreviewModal
|
||||
open
|
||||
onClose={closeQueuePaste}
|
||||
payload={queuePaste}
|
||||
preview={queuePreview}
|
||||
shareServerLabel={queuePasteServerLabel}
|
||||
coverServer={queuePasteCoverServer}
|
||||
onEnqueue={() => void confirmQueuePaste()}
|
||||
enqueueBusy={queuePasteBusy}
|
||||
confirmLabel={t('sharePaste.playQueue')}
|
||||
confirmBusyLabel={t('sharePaste.playQueueing')}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={!!orbitConfirm}
|
||||
title={t('orbit.confirmJoinTitle')}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
||||
import CachedImage from '../CachedImage';
|
||||
import OverlayScrollArea from '../OverlayScrollArea';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
@@ -24,6 +26,8 @@ type ShareQueuePreviewModalProps = {
|
||||
coverServer?: ServerProfile | null;
|
||||
onEnqueue: () => void;
|
||||
enqueueBusy: boolean;
|
||||
confirmLabel?: string;
|
||||
confirmBusyLabel?: string;
|
||||
};
|
||||
|
||||
function QueuePreviewTrackRow({
|
||||
@@ -93,11 +97,18 @@ function PreviewBody({
|
||||
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
|
||||
</p>
|
||||
)}
|
||||
<ul className="share-queue-preview-modal__list">
|
||||
{preview.songs.map(song => (
|
||||
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
|
||||
))}
|
||||
</ul>
|
||||
<OverlayScrollArea
|
||||
className="share-queue-preview-modal__list-wrap"
|
||||
viewportClassName="share-queue-preview-modal__list-viewport"
|
||||
measureDeps={[preview.songs.length, preview.skipped]}
|
||||
railInset="panel"
|
||||
>
|
||||
<ul className="share-queue-preview-modal__list">
|
||||
{preview.songs.map(song => (
|
||||
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
|
||||
))}
|
||||
</ul>
|
||||
</OverlayScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -111,16 +122,30 @@ export default function ShareQueuePreviewModal({
|
||||
coverServer,
|
||||
onEnqueue,
|
||||
enqueueBusy,
|
||||
confirmLabel,
|
||||
confirmBusyLabel,
|
||||
}: ShareQueuePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionLabel = confirmLabel ?? t('search.shareQueueAction');
|
||||
const actionBusyLabel = confirmBusyLabel ?? t('search.shareQueueing');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const blockContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
document.addEventListener('contextmenu', blockContextMenu, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
document.removeEventListener('contextmenu', blockContextMenu, true);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -132,6 +157,10 @@ export default function ShareQueuePreviewModal({
|
||||
<div
|
||||
className="modal-overlay share-queue-preview-modal-overlay"
|
||||
role="presentation"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
@@ -141,6 +170,10 @@ export default function ShareQueuePreviewModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="share-queue-preview-title"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
@@ -172,7 +205,7 @@ export default function ShareQueuePreviewModal({
|
||||
disabled={!canEnqueue || enqueueBusy}
|
||||
onClick={() => void onEnqueue()}
|
||||
>
|
||||
{enqueueBusy ? t('search.shareQueueing') : t('search.shareQueueAction')}
|
||||
{enqueueBusy ? actionBusyLabel : actionLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user