mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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:
+9
-9
@@ -30,15 +30,6 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
|||||||
|
|
||||||
## Added
|
## 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
|
### HTTP — gzip + brotli decompression for the Rust-side clients
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#704](https://github.com/Psychotoxical/psysonic/pull/704)**
|
**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.
|
* 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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
|
||||||
import { decodeSharePayloadFromText } from '../utils/share/shareLink';
|
import { decodeSharePayloadFromText } from '../utils/share/shareLink';
|
||||||
import { decodeServerMagicStringFromText } from '../utils/server/serverMagicString';
|
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 { showToast } from '../utils/ui/toast';
|
||||||
|
import { useShareQueuePreview } from '../hooks/useShareQueuePreview';
|
||||||
|
import ShareQueuePreviewModal from './search/ShareQueuePreviewModal';
|
||||||
import {
|
import {
|
||||||
parseOrbitShareLink,
|
parseOrbitShareLink,
|
||||||
joinOrbitSession,
|
joinOrbitSession,
|
||||||
@@ -30,13 +34,27 @@ const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
|||||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||||
*/
|
*/
|
||||||
|
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||||
|
|
||||||
export default function PasteClipboardHandler() {
|
export default function PasteClipboardHandler() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||||
|
const servers = useAuthStore(s => s.servers);
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const busy = useRef(false);
|
const busy = useRef(false);
|
||||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
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"
|
// `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
|
// 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');
|
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busy.current) {
|
if (busy.current || queuePaste) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (share.k === 'queue') {
|
||||||
|
if (share.ids.length === 0) {
|
||||||
|
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQueuePaste(share);
|
||||||
|
return;
|
||||||
|
}
|
||||||
busy.current = true;
|
busy.current = true;
|
||||||
void applySharePastePayload(share, navigate, t).finally(() => {
|
void applySharePastePayload(share, navigate, t).finally(() => {
|
||||||
busy.current = false;
|
busy.current = false;
|
||||||
@@ -171,10 +197,37 @@ export default function PasteClipboardHandler() {
|
|||||||
};
|
};
|
||||||
document.addEventListener('paste', onPaste, true);
|
document.addEventListener('paste', onPaste, true);
|
||||||
return () => document.removeEventListener('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 (
|
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
|
<ConfirmModal
|
||||||
open={!!orbitConfirm}
|
open={!!orbitConfirm}
|
||||||
title={t('orbit.confirmJoinTitle')}
|
title={t('orbit.confirmJoinTitle')}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { formatTrackTime } from '../../utils/format/formatDuration';
|
|||||||
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
||||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
||||||
import CachedImage from '../CachedImage';
|
import CachedImage from '../CachedImage';
|
||||||
|
import OverlayScrollArea from '../OverlayScrollArea';
|
||||||
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import {
|
import {
|
||||||
buildCoverArtUrl,
|
buildCoverArtUrl,
|
||||||
buildCoverArtUrlForServer,
|
buildCoverArtUrlForServer,
|
||||||
@@ -24,6 +26,8 @@ type ShareQueuePreviewModalProps = {
|
|||||||
coverServer?: ServerProfile | null;
|
coverServer?: ServerProfile | null;
|
||||||
onEnqueue: () => void;
|
onEnqueue: () => void;
|
||||||
enqueueBusy: boolean;
|
enqueueBusy: boolean;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmBusyLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function QueuePreviewTrackRow({
|
function QueuePreviewTrackRow({
|
||||||
@@ -93,11 +97,18 @@ function PreviewBody({
|
|||||||
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
|
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="share-queue-preview-modal__list">
|
<OverlayScrollArea
|
||||||
{preview.songs.map(song => (
|
className="share-queue-preview-modal__list-wrap"
|
||||||
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
|
viewportClassName="share-queue-preview-modal__list-viewport"
|
||||||
))}
|
measureDeps={[preview.songs.length, preview.skipped]}
|
||||||
</ul>
|
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,
|
coverServer,
|
||||||
onEnqueue,
|
onEnqueue,
|
||||||
enqueueBusy,
|
enqueueBusy,
|
||||||
|
confirmLabel,
|
||||||
|
confirmBusyLabel,
|
||||||
}: ShareQueuePreviewModalProps) {
|
}: ShareQueuePreviewModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const actionLabel = confirmLabel ?? t('search.shareQueueAction');
|
||||||
|
const actionBusyLabel = confirmBusyLabel ?? t('search.shareQueueing');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
usePlayerStore.getState().closeContextMenu();
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
|
const blockContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
usePlayerStore.getState().closeContextMenu();
|
||||||
|
};
|
||||||
document.addEventListener('keydown', handler);
|
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]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
@@ -132,6 +157,10 @@ export default function ShareQueuePreviewModal({
|
|||||||
<div
|
<div
|
||||||
className="modal-overlay share-queue-preview-modal-overlay"
|
className="modal-overlay share-queue-preview-modal-overlay"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
onMouseDown={e => {
|
onMouseDown={e => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
@@ -141,6 +170,10 @@ export default function ShareQueuePreviewModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="share-queue-preview-title"
|
aria-labelledby="share-queue-preview-title"
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
onMouseDown={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||||
@@ -172,7 +205,7 @@ export default function ShareQueuePreviewModal({
|
|||||||
disabled={!canEnqueue || enqueueBusy}
|
disabled={!canEnqueue || enqueueBusy}
|
||||||
onClick={() => void onEnqueue()}
|
onClick={() => void onEnqueue()}
|
||||||
>
|
>
|
||||||
{enqueueBusy ? t('search.shareQueueing') : t('search.shareQueueAction')}
|
{enqueueBusy ? actionBusyLabel : actionLabel}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Search: filter search3 artist rows with zero albums (report: zunoz on Psysonic Discord) (PR #697)',
|
'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)',
|
'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)',
|
'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)',
|
'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',
|
github: 'Psychotoxical',
|
||||||
since: '1.0.0',
|
since: '1.0.0',
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'{{played}} von {{total}} Titeln aus dem Link werden abgespielt ({{skipped}} auf diesem Server nicht gefunden).',
|
'{{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.',
|
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.',
|
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'Playing {{played}} of {{total}} tracks from the link ({{skipped}} not found on this server).',
|
'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.',
|
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.',
|
genericError: 'Could not open the share link.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'Reproduciendo {{played}} de {{total}} pistas del enlace ({{skipped}} no encontradas en este servidor).',
|
'Reproduciendo {{played}} de {{total}} pistas del enlace ({{skipped}} no encontradas en este servidor).',
|
||||||
queueAllUnavailable: 'Ninguna pista de este enlace se encontró en el 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.',
|
genericError: 'No se pudo abrir el enlace para compartir.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'{{played}} sur {{total}} morceaux du lien : {{skipped}} introuvable(s) sur ce serveur.',
|
'{{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.',
|
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.',
|
genericError: 'Impossible d’ouvrir le lien de partage.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'Spiller {{played}} av {{total}} spor fra lenken ({{skipped}} ble ikke funnet på denne serveren).',
|
'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.',
|
queueAllUnavailable: 'Ingen av sporene fra denne lenken ble funnet på serveren.',
|
||||||
|
playQueue: 'Play queue',
|
||||||
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Klarte ikke å åpne delingslenken.',
|
genericError: 'Klarte ikke å åpne delingslenken.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'{{played}} van {{total}} nummers uit de link worden afgespeeld ({{skipped}} niet gevonden op deze server).',
|
'{{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.',
|
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.',
|
genericError: 'De deellink kon niet worden geopend.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'Se redau {{played}} din {{total}} piese din link ({{skipped}} nu au fost găsite în server).',
|
'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.',
|
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.',
|
genericError: 'Nu s-a putut deschide link-ul distribuit.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ export const sharePaste = {
|
|||||||
openedQueuePartial:
|
openedQueuePartial:
|
||||||
'Воспроизводится {{played}} из {{total}} треков по ссылке ({{skipped}} на этом сервере не найдены).',
|
'Воспроизводится {{played}} из {{total}} треков по ссылке ({{skipped}} на этом сервере не найдены).',
|
||||||
queueAllUnavailable: 'Ни один из треков по ссылке не найден на сервере.',
|
queueAllUnavailable: 'Ни один из треков по ссылке не найден на сервере.',
|
||||||
|
playQueue: 'Воспроизвести очередь',
|
||||||
|
playQueueing: 'Запуск воспроизведения…',
|
||||||
genericError: 'Не удалось открыть ссылку для обмена.',
|
genericError: 'Не удалось открыть ссылку для обмена.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,5 +13,7 @@ export const sharePaste = {
|
|||||||
openedQueue_other: '正在播放分享队列中的 {{count}} 首曲目。',
|
openedQueue_other: '正在播放分享队列中的 {{count}} 首曲目。',
|
||||||
openedQueuePartial: '正在播放链接中的 {{played}} / {{total}} 首曲目({{skipped}} 首在此服务器上未找到)。',
|
openedQueuePartial: '正在播放链接中的 {{played}} / {{total}} 首曲目({{skipped}} 首在此服务器上未找到)。',
|
||||||
queueAllUnavailable: '此链接中的曲目在服务器上均未找到。',
|
queueAllUnavailable: '此链接中的曲目在服务器上均未找到。',
|
||||||
|
playQueue: 'Play queue',
|
||||||
|
playQueueing: 'Starting playback…',
|
||||||
genericError: '无法打开分享链接。',
|
genericError: '无法打开分享链接。',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
.share-queue-preview-modal-overlay {
|
.share-queue-preview-modal-overlay {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
/* Above .context-menu (10000) and .context-submenu (10001). */
|
||||||
|
z-index: 10002;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-queue-preview-modal {
|
.share-queue-preview-modal {
|
||||||
@@ -59,12 +61,17 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-queue-preview-modal__list {
|
.share-queue-preview-modal__list-wrap.overlay-scroll {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
max-height: min(52vh, 480px);
|
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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -11,6 +11,82 @@ import { showToast } from '../ui/toast';
|
|||||||
|
|
||||||
const RESOLVE_QUEUE_CHUNK = 12;
|
const RESOLVE_QUEUE_CHUNK = 12;
|
||||||
|
|
||||||
|
type SharePasteQueuePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* Switches to the matching server, validates the entity on the server, then
|
||||||
* plays or navigates. Caller should `preventDefault` on the paste event when
|
* plays or navigates. Caller should `preventDefault` on the paste event when
|
||||||
@@ -91,37 +167,7 @@ export async function applySharePastePayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.k === 'queue') {
|
if (payload.k === 'queue') {
|
||||||
const { ids } = payload;
|
await applySharePasteQueue(payload, t);
|
||||||
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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -23,3 +23,20 @@ export function shareServerOriginLabel(
|
|||||||
|
|
||||||
return serverListDisplayLabel(server, servers);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user