From 33b0206ea2144368f39fad78b9b67f8d76b61096 Mon Sep 17 00:00:00 2001
From: Maxim Isaev
Date: Fri, 15 May 2026 13:52:07 +0300
Subject: [PATCH] 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).
---
CHANGELOG.md | 18 +--
src/components/PasteClipboardHandler.tsx | 61 +++++++++-
.../search/ShareQueuePreviewModal.tsx | 47 ++++++--
src/config/settingsCredits.ts | 8 ++
src/locales/de/sharePaste.ts | 2 +
src/locales/en/sharePaste.ts | 2 +
src/locales/es/sharePaste.ts | 2 +
src/locales/fr/sharePaste.ts | 2 +
src/locales/nb/sharePaste.ts | 2 +
src/locales/nl/sharePaste.ts | 2 +
src/locales/ro/sharePaste.ts | 2 +
src/locales/ru/sharePaste.ts | 2 +
src/locales/zh/sharePaste.ts | 2 +
.../components/share-queue-preview-modal.css | 15 ++-
src/utils/share/applySharePaste.ts | 108 +++++++++++++-----
src/utils/share/shareServerOriginLabel.ts | 17 +++
16 files changed, 237 insertions(+), 55 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cae4a1a9..ed750233 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,15 +30,6 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
## Added
-### Search — queue pasted share links from Live Search and mobile search
-
-Inspired by [@DanielWTE](https://github.com/DanielWTE)'s [PR #551](https://github.com/Psychotoxical/psysonic/pull/551).
-
-* Pasting a `psysonic2-` share link into **Live Search** or the **mobile search overlay** shows a dedicated share row: track and queue links **enqueue** (append) instead of replacing the queue like global paste does; album, artist, and composer links preview metadata **without switching the active server**, then navigate on confirm.
-* Queue share links expose a **Preview** action that opens a scrollable track list (resolved lazily against the share server) before **Add to queue**.
-* Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue.
-* i18n: `search.share*` keys across all 9 locales.
-
### HTTP — gzip + brotli decompression for the Rust-side clients
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#704](https://github.com/Psychotoxical/psysonic/pull/704)**
@@ -165,6 +156,15 @@ Inspired by [@DanielWTE](https://github.com/DanielWTE)'s [PR #551](https://githu
* Complete Romanian (`ro`) locale for navigation, player, playlists, settings, help, and errors.
* Psysonic now ships in **nine** UI languages: English, German, Spanish, French, Dutch, Norwegian Bokmål, Russian, Chinese (Simplified), and Romanian.
+### Search — queue pasted share links from Live Search and mobile search
+
+**By [@cucadmuh](https://github.com/cucadmuh), inspired by [@DanielWTE](https://github.com/DanielWTE)'s [PR #551](https://github.com/Psychotoxical/psysonic/pull/551), PR [#716](https://github.com/Psychotoxical/psysonic/pull/716)**
+
+* Pasting a `psysonic2-` share link into **Live Search** or the **mobile search overlay** shows a dedicated share row: track and queue links **enqueue** (append) instead of replacing the queue like global paste does; album, artist, and composer links preview metadata **without switching the active server**, then navigate on confirm.
+* Queue share links expose a **Preview** action that opens a scrollable track list (resolved lazily against the share server) before **Add to queue** (Live Search / mobile search) or **Play queue** (global Ctrl+V paste).
+* Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue.
+* i18n: `search.share*` keys across all 9 locales.
+
## Changed
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx
index 466dd079..c1859ebf 100644
--- a/src/components/PasteClipboardHandler.tsx
+++ b/src/components/PasteClipboardHandler.tsx
@@ -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 = {
* 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;
+
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(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 && (
+ void confirmQueuePaste()}
+ enqueueBusy={queuePasteBusy}
+ confirmLabel={t('sharePaste.playQueue')}
+ confirmBusyLabel={t('sharePaste.playQueueing')}
+ />
+ )}
void;
enqueueBusy: boolean;
+ confirmLabel?: string;
+ confirmBusyLabel?: string;
};
function QueuePreviewTrackRow({
@@ -93,11 +97,18 @@ function PreviewBody({
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
)}
-
- {preview.songs.map(song => (
-
- ))}
-
+
+
+ {preview.songs.map(song => (
+
+ ))}
+
+
>
);
}
@@ -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({
{
+ 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()}
>
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 0b338ad1..fc4bca77 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -111,6 +111,7 @@ const CONTRIBUTOR_ENTRIES = [
'Search: filter search3 artist rows with zero albums (report: zunoz on Psysonic Discord) (PR #697)',
'Internet Radio: portal add/edit station modal to document.body — fixes clipped modal on empty library (report: voidboywannabe on Psysonic Discord) (PR #699)',
'Now Playing: composite list keys on similar artists, album-card tracklist, and top songs — avoids duplicate React keys when Subsonic repeats ids (PR #703)',
+ 'Search: share links in live/mobile search + queue preview modal (PR #716)',
],
},
{
@@ -190,6 +191,13 @@ const CONTRIBUTOR_ENTRIES = [
'Romanian (ro) full UI translation (PR #663)',
],
},
+ {
+ github: 'DanielWTE',
+ since: '1.46.0',
+ contributions: [
+ 'Search: queue pasted share links from live search (PR #551)',
+ ],
+ },
{
github: 'Psychotoxical',
since: '1.0.0',
diff --git a/src/locales/de/sharePaste.ts b/src/locales/de/sharePaste.ts
index 1596cd99..f6d5467f 100644
--- a/src/locales/de/sharePaste.ts
+++ b/src/locales/de/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'{{played}} von {{total}} Titeln aus dem Link werden abgespielt ({{skipped}} auf diesem Server nicht gefunden).',
queueAllUnavailable: 'Keiner der Titel aus diesem Link wurde auf dem Server gefunden.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
};
diff --git a/src/locales/en/sharePaste.ts b/src/locales/en/sharePaste.ts
index 35becca2..f34386a0 100644
--- a/src/locales/en/sharePaste.ts
+++ b/src/locales/en/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'Playing {{played}} of {{total}} tracks from the link ({{skipped}} not found on this server).',
queueAllUnavailable: 'None of the tracks from this link were found on the server.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'Could not open the share link.',
};
diff --git a/src/locales/es/sharePaste.ts b/src/locales/es/sharePaste.ts
index 464bdf84..69c64e95 100644
--- a/src/locales/es/sharePaste.ts
+++ b/src/locales/es/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'Reproduciendo {{played}} de {{total}} pistas del enlace ({{skipped}} no encontradas en este servidor).',
queueAllUnavailable: 'Ninguna pista de este enlace se encontró en el servidor.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'No se pudo abrir el enlace para compartir.',
};
diff --git a/src/locales/fr/sharePaste.ts b/src/locales/fr/sharePaste.ts
index 0321cc02..19e47bdd 100644
--- a/src/locales/fr/sharePaste.ts
+++ b/src/locales/fr/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'{{played}} sur {{total}} morceaux du lien : {{skipped}} introuvable(s) sur ce serveur.',
queueAllUnavailable: 'Aucun morceau de ce lien n’a été trouvé sur le serveur.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'Impossible d’ouvrir le lien de partage.',
};
diff --git a/src/locales/nb/sharePaste.ts b/src/locales/nb/sharePaste.ts
index 94a7414f..5fcb84d5 100644
--- a/src/locales/nb/sharePaste.ts
+++ b/src/locales/nb/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'Spiller {{played}} av {{total}} spor fra lenken ({{skipped}} ble ikke funnet på denne serveren).',
queueAllUnavailable: 'Ingen av sporene fra denne lenken ble funnet på serveren.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'Klarte ikke å åpne delingslenken.',
};
diff --git a/src/locales/nl/sharePaste.ts b/src/locales/nl/sharePaste.ts
index 9bc1ae22..c70cb7c0 100644
--- a/src/locales/nl/sharePaste.ts
+++ b/src/locales/nl/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'{{played}} van {{total}} nummers uit de link worden afgespeeld ({{skipped}} niet gevonden op deze server).',
queueAllUnavailable: 'Geen enkel nummer uit deze link is op de server gevonden.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'De deellink kon niet worden geopend.',
};
diff --git a/src/locales/ro/sharePaste.ts b/src/locales/ro/sharePaste.ts
index 9e959683..cb0557ce 100644
--- a/src/locales/ro/sharePaste.ts
+++ b/src/locales/ro/sharePaste.ts
@@ -14,5 +14,7 @@ export const sharePaste = {
openedQueuePartial:
'Se redau {{played}} din {{total}} piese din link ({{skipped}} nu au fost găsite în server).',
queueAllUnavailable: 'Nicio piesă din acest link nu a fost găsită în server.',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: 'Nu s-a putut deschide link-ul distribuit.',
};
diff --git a/src/locales/ru/sharePaste.ts b/src/locales/ru/sharePaste.ts
index 0dbe2da8..aeaea042 100644
--- a/src/locales/ru/sharePaste.ts
+++ b/src/locales/ru/sharePaste.ts
@@ -16,5 +16,7 @@ export const sharePaste = {
openedQueuePartial:
'Воспроизводится {{played}} из {{total}} треков по ссылке ({{skipped}} на этом сервере не найдены).',
queueAllUnavailable: 'Ни один из треков по ссылке не найден на сервере.',
+ playQueue: 'Воспроизвести очередь',
+ playQueueing: 'Запуск воспроизведения…',
genericError: 'Не удалось открыть ссылку для обмена.',
};
diff --git a/src/locales/zh/sharePaste.ts b/src/locales/zh/sharePaste.ts
index d4594eb8..ad0c4708 100644
--- a/src/locales/zh/sharePaste.ts
+++ b/src/locales/zh/sharePaste.ts
@@ -13,5 +13,7 @@ export const sharePaste = {
openedQueue_other: '正在播放分享队列中的 {{count}} 首曲目。',
openedQueuePartial: '正在播放链接中的 {{played}} / {{total}} 首曲目({{skipped}} 首在此服务器上未找到)。',
queueAllUnavailable: '此链接中的曲目在服务器上均未找到。',
+ playQueue: 'Play queue',
+ playQueueing: 'Starting playback…',
genericError: '无法打开分享链接。',
};
diff --git a/src/styles/components/share-queue-preview-modal.css b/src/styles/components/share-queue-preview-modal.css
index 4060683a..735cca8f 100644
--- a/src/styles/components/share-queue-preview-modal.css
+++ b/src/styles/components/share-queue-preview-modal.css
@@ -2,6 +2,8 @@
.share-queue-preview-modal-overlay {
align-items: center;
padding-top: 0;
+ /* Above .context-menu (10000) and .context-submenu (10001). */
+ z-index: 10002;
}
.share-queue-preview-modal {
@@ -59,12 +61,17 @@
color: var(--text-muted);
}
-.share-queue-preview-modal__list {
- flex: 1;
+.share-queue-preview-modal__list-wrap.overlay-scroll {
+ flex: 1 1 auto;
min-height: 120px;
max-height: min(52vh, 480px);
- overflow-y: auto;
- overscroll-behavior: contain;
+}
+
+.share-queue-preview-modal__list-viewport {
+ padding-right: var(--space-1);
+}
+
+.share-queue-preview-modal__list {
margin: 0;
padding: 0;
list-style: none;
diff --git a/src/utils/share/applySharePaste.ts b/src/utils/share/applySharePaste.ts
index 911b1d09..63e96842 100644
--- a/src/utils/share/applySharePaste.ts
+++ b/src/utils/share/applySharePaste.ts
@@ -11,6 +11,82 @@ import { showToast } from '../ui/toast';
const RESOLVE_QUEUE_CHUNK = 12;
+type SharePasteQueuePayload = Extract;
+
+async function resolveSharePasteQueueSongs(
+ ids: string[],
+): Promise<{ songs: SubsonicSong[]; skipped: number } | null> {
+ if (ids.length === 0) return null;
+
+ const resolved: SubsonicSong[] = [];
+ for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) {
+ const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK);
+ const songs = await Promise.all(chunk.map(id => getSong(id)));
+ for (const s of songs) {
+ if (s) resolved.push(s);
+ }
+ }
+
+ const skipped = ids.length - resolved.length;
+ if (resolved.length === 0) return null;
+ return { songs: resolved, skipped };
+}
+
+/**
+ * Switches to the matching server, resolves queue track ids, then replaces the
+ * queue and starts playback. Used after the queue preview modal on global paste.
+ */
+export async function applySharePasteQueue(
+ payload: SharePasteQueuePayload,
+ t: TFunction,
+): Promise {
+ const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
+ if (!isLoggedIn) {
+ showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
+ return false;
+ }
+
+ const serverId = findServerIdForShareUrl(servers, payload.srv);
+ if (!serverId) {
+ showToast(t('sharePaste.noMatchingServer', { url: payload.srv }), 6000, 'error');
+ return false;
+ }
+
+ if (useAuthStore.getState().activeServerId !== serverId) {
+ setActiveServer(serverId);
+ }
+
+ try {
+ const result = await resolveSharePasteQueueSongs(payload.ids);
+ if (!result) {
+ showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
+ return false;
+ }
+
+ const tracks = result.songs.map(songToTrack);
+ usePlayerStore.getState().clearQueue();
+ usePlayerStore.getState().playTrack(tracks[0]!, tracks);
+ if (result.skipped > 0) {
+ showToast(
+ t('sharePaste.openedQueuePartial', {
+ played: tracks.length,
+ total: payload.ids.length,
+ skipped: result.skipped,
+ }),
+ 5000,
+ 'info',
+ );
+ } else {
+ showToast(t('sharePaste.openedQueue', { count: tracks.length }), 3000, 'info');
+ }
+ return true;
+ } catch (e) {
+ console.error('[psysonic] share paste queue failed', e);
+ showToast(t('sharePaste.genericError'), 5000, 'error');
+ return false;
+ }
+}
+
/**
* Switches to the matching server, validates the entity on the server, then
* plays or navigates. Caller should `preventDefault` on the paste event when
@@ -91,37 +167,7 @@ export async function applySharePastePayload(
}
if (payload.k === 'queue') {
- const { ids } = payload;
- if (ids.length === 0) {
- showToast(t('sharePaste.genericError'), 5000, 'error');
- return;
- }
- const resolved: SubsonicSong[] = [];
- for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) {
- const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK);
- const songs = await Promise.all(chunk.map(id => getSong(id)));
- for (let j = 0; j < songs.length; j++) {
- const s = songs[j];
- if (s) resolved.push(s);
- }
- }
- const skipped = ids.length - resolved.length;
- if (resolved.length === 0) {
- showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
- return;
- }
- const tracks = resolved.map(songToTrack);
- usePlayerStore.getState().clearQueue();
- usePlayerStore.getState().playTrack(tracks[0]!, tracks);
- if (skipped > 0) {
- showToast(
- t('sharePaste.openedQueuePartial', { played: tracks.length, total: ids.length, skipped }),
- 5000,
- 'info',
- );
- } else {
- showToast(t('sharePaste.openedQueue', { count: tracks.length }), 3000, 'info');
- }
+ await applySharePasteQueue(payload, t);
return;
}
} catch (e) {
diff --git a/src/utils/share/shareServerOriginLabel.ts b/src/utils/share/shareServerOriginLabel.ts
index 3df7dd59..a927f819 100644
--- a/src/utils/share/shareServerOriginLabel.ts
+++ b/src/utils/share/shareServerOriginLabel.ts
@@ -23,3 +23,20 @@ export function shareServerOriginLabel(
return serverListDisplayLabel(server, servers);
}
+
+/** Server label and profile for queue preview when the link targets another saved server. */
+export function shareQueueServerContext(
+ shareSrv: string,
+ servers: ServerProfile[],
+ activeServerId: string | null,
+): { label: string | null; coverServer: ServerProfile | null } {
+ const match: ShareSearchMatch = {
+ type: 'queueable',
+ payload: { srv: shareSrv, k: 'queue', ids: [] },
+ };
+ const label = shareServerOriginLabel(match, servers, activeServerId);
+ const serverId = findServerIdForShareUrl(servers, shareSrv);
+ const coverServer =
+ serverId && serverId !== activeServerId ? servers.find(s => s.id === serverId) ?? null : null;
+ return { label, coverServer };
+}