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:
Maxim Isaev
2026-05-15 13:52:07 +03:00
parent e7431b94b8
commit 33b0206ea2
16 changed files with 237 additions and 55 deletions
+77 -31
View File
@@ -11,6 +11,82 @@ import { showToast } from '../ui/toast';
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
* 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) {
+17
View File
@@ -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 };
}