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 }; +}