feat(search): queue pasted share links from Live Search and mobile search

Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.

Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
This commit is contained in:
Maxim Isaev
2026-05-15 13:38:35 +03:00
parent 02fd828049
commit e7431b94b8
30 changed files with 2323 additions and 16 deletions
@@ -0,0 +1,182 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Music, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes';
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 {
buildCoverArtUrl,
buildCoverArtUrlForServer,
coverArtCacheKey,
coverArtCacheKeyForServer,
} from '../../api/subsonicStreamUrl';
type ShareQueuePreviewModalProps = {
open: boolean;
onClose: () => void;
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }>;
preview: ShareQueuePreviewState;
shareServerLabel?: string | null;
coverServer?: ServerProfile | null;
onEnqueue: () => void;
enqueueBusy: boolean;
};
function QueuePreviewTrackRow({
song,
coverServer,
}: {
song: SubsonicSong;
coverServer?: ServerProfile | null;
}) {
const coverId = song.coverArt ?? '';
const src = coverServer
? buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId || song.id, 48)
: buildCoverArtUrl(coverId || song.id, 48);
const cacheKey = coverServer
? coverArtCacheKeyForServer(coverServer.id, coverId || song.id, 48)
: coverArtCacheKey(coverId || song.id, 48);
return (
<li className="share-queue-preview-track">
{coverId ? (
<CachedImage className="share-queue-preview-track__thumb" src={src} cacheKey={cacheKey} alt="" />
) : (
<div className="share-queue-preview-track__icon">
<Music size={16} />
</div>
)}
<div className="share-queue-preview-track__meta">
<div className="share-queue-preview-track__title">{song.title}</div>
<div className="share-queue-preview-track__sub">
{song.artist}{song.album ? ` · ${song.album}` : ''}
</div>
</div>
<span className="share-queue-preview-track__dur">{formatTrackTime(song.duration)}</span>
</li>
);
}
function PreviewBody({
preview,
coverServer,
}: {
preview: ShareQueuePreviewState;
coverServer?: ServerProfile | null;
}) {
const { t } = useTranslation();
if (preview.status === 'loading' || preview.status === 'idle') {
return <div className="share-queue-preview-modal__status">{t('search.shareQueuePreviewLoading')}</div>;
}
if (preview.status === 'error') {
const msg =
preview.result.type === 'not-logged-in'
? t('sharePaste.notLoggedIn')
: preview.result.type === 'no-matching-server'
? t('sharePaste.noMatchingServer', { url: preview.result.url })
: preview.result.type === 'all-unavailable'
? t('search.shareQueuePreviewEmpty')
: t('sharePaste.genericError');
return <div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">{msg}</div>;
}
return (
<>
{preview.skipped > 0 && (
<p className="share-queue-preview-modal__skipped">
{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>
</>
);
}
export default function ShareQueuePreviewModal({
open,
onClose,
payload,
preview,
shareServerLabel,
coverServer,
onEnqueue,
enqueueBusy,
}: ShareQueuePreviewModalProps) {
const { t } = useTranslation();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
const count = sharePayloadTotal(payload);
const canEnqueue = preview.status === 'ok' && preview.songs.length > 0;
return createPortal(
<div
className="modal-overlay share-queue-preview-modal-overlay"
role="presentation"
onMouseDown={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="modal-content share-queue-preview-modal"
role="dialog"
aria-modal="true"
aria-labelledby="share-queue-preview-title"
onMouseDown={e => e.stopPropagation()}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
<header className="share-queue-preview-modal__header">
<h2 id="share-queue-preview-title" className="share-queue-preview-modal__title">
{t('search.shareQueueTitle', { count })}
</h2>
{shareServerLabel && (
<p className="share-queue-preview-modal__server">
{t('search.shareFromServer', { server: shareServerLabel })}
</p>
)}
</header>
<div className="share-queue-preview-modal__body">
<PreviewBody preview={preview} coverServer={coverServer} />
</div>
<footer className="share-queue-preview-modal__footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canEnqueue || enqueueBusy}
onClick={() => void onEnqueue()}
>
{enqueueBusy ? t('search.shareQueueing') : t('search.shareQueueAction')}
</button>
</footer>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,454 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import {
buildCoverArtUrl,
buildCoverArtUrlForServer,
coverArtCacheKey,
coverArtCacheKeyForServer,
} from '../../api/subsonicStreamUrl';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack';
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
type ShareSearchResultsProps = {
variant: 'desktop' | 'mobile';
shareMatch: ShareSearchMatch;
/** Saved server display name when the link is from a non-active server. */
shareServerLabel?: string | null;
/** Saved server profile for cover art when the link is from a non-active server. */
shareCoverServer?: ServerProfile | null;
activeIndex?: number;
shareQueueBusy: boolean;
onEnqueue: () => void | Promise<boolean>;
onOpenAlbum: () => void;
onOpenArtist: () => void;
onOpenComposer: () => void;
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
} & ShareSearchPreviewState;
function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) {
return useMemo(() => {
if (coverServer) {
return {
src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size),
cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size),
};
}
return {
src: buildCoverArtUrl(coverId, size),
cacheKey: coverArtCacheKey(coverId, size),
};
}, [coverServer, coverId, size]);
}
function ShareAlbumThumb({
coverArt,
size,
coverServer,
}: {
coverArt: string;
size: number;
coverServer?: ServerProfile | null;
}) {
const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer);
const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
return <CachedImage className={cls} src={src} cacheKey={cacheKey} alt="" />;
}
function ShareArtistThumb({
artist,
size,
coverServer,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
size: number;
coverServer?: ServerProfile | null;
}) {
const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id;
const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer);
useEffect(() => { setFailed(false); }, [coverId]);
if (failed) {
if (size >= 64) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
);
}
return (
<div className="search-result-icon">
<Users size={14} />
</div>
);
}
const cls =
size >= 64
? 'mobile-search-thumb mobile-search-thumb--artist-round'
: 'search-result-thumb';
return (
<CachedImage
className={cls}
src={src}
cacheKey={cacheKey}
alt=""
loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
function StaticIcon({ className, children }: { className: string; children: React.ReactNode }) {
return <div className={className}>{children}</div>;
}
function withShareServer(
shareMatch: ShareSearchMatch,
t: TFunction,
fn: () => void,
): void {
if (shareMatch.type === 'unsupported') return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
fn();
}
function shareSubLine(primary: string, serverLabel: string | null | undefined, t: TFunction): string {
if (!serverLabel) return primary;
const hint = t('search.shareFromServer', { server: serverLabel });
return primary ? `${primary} · ${hint}` : hint;
}
export default function ShareSearchResults(props: ShareSearchResultsProps) {
const {
variant,
shareMatch,
shareServerLabel = null,
shareCoverServer = null,
activeIndex = 0,
shareQueueBusy,
onEnqueue,
onOpenAlbum,
onOpenArtist,
onOpenComposer,
onContextMenu,
shareTrackSong,
shareTrackResolving,
shareTrackUnavailable,
shareAlbum,
shareAlbumResolving,
shareAlbumUnavailable,
shareArtist,
shareArtistResolving,
shareArtistUnavailable,
shareComposer,
shareComposerResolving,
shareComposerUnavailable,
} = props;
const { t } = useTranslation();
const desktop = variant === 'desktop';
const thumbSize = desktop ? 40 : 80;
const sectionCls = desktop ? 'search-section' : 'mobile-search-section';
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label';
const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted';
const iconCls = desktop ? 'search-result-icon' : 'mobile-search-avatar';
const nameCls = desktop ? 'search-result-name' : 'mobile-search-item-title';
const subCls = desktop ? 'search-result-sub' : 'mobile-search-item-sub';
const infoWrap = desktop ? undefined : 'mobile-search-item-info';
const wrap = (content: React.ReactNode) => (
<div className={sectionCls}>
<div className={labelCls}>
{desktop && <Link2 size={12} />} {t('search.shareLink')}
</div>
{content}
</div>
);
const itemCls = (active: boolean) =>
desktop ? `search-result-item${active ? ' active' : ''}` : 'mobile-search-item';
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
const showEntityKindSub = !desktop || !!shareServerLabel;
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
const queuePayload =
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
const unsupportedRow = (
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('search.shareUnsupportedTitle')}</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>
);
if (shareMatch.type === 'unsupported') {
return wrap(unsupportedRow);
}
if (shareMatch.type === 'artist') {
if (shareArtistResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.artists'))}</div>
</div>
</div>,
);
}
if (shareArtist) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenArtist}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareArtist, 'artist'));
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<ShareArtistThumb artist={shareArtist} size={thumbSize} coverServer={shareCoverServer} />
<div className={infoWrap}>
<div className={nameCls}>{shareArtist.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</div>}
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareArtistUnavailable ? t('sharePaste.artistUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'composer') {
if (shareComposerResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('sidebar.composers'))}</div>
</div>
</div>,
);
}
if (shareComposer) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenComposer}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<ShareArtistThumb artist={shareComposer} size={thumbSize} coverServer={shareCoverServer} />
<div className={infoWrap}>
<div className={nameCls}>{shareComposer.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</div>}
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareComposerUnavailable ? t('sharePaste.composerUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'album') {
if (shareAlbumResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.album'))}</div>
</div>
</div>,
);
}
if (shareAlbum) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenAlbum}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareAlbum, 'album'));
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareAlbum.coverArt ? (
<ShareAlbumThumb coverArt={shareAlbum.coverArt} size={thumbSize} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
)}
<div className={infoWrap}>
<div className={nameCls}>{shareAlbum.name}</div>
<div className={subCls}>{sub(shareAlbum.artist)}</div>
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareAlbumUnavailable ? t('sharePaste.albumUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'track') {
if (shareTrackResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.shareTrackTitle'))}</div>
</div>
</div>,
);
}
if (shareTrackSong) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onEnqueue}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, songToTrack(shareTrackSong), 'song'));
}}
disabled={shareQueueBusy}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareTrackSong.coverArt ? (
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} size={thumbSize} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
)}
<div className={infoWrap}>
<div className={nameCls}>{shareTrackSong.title}</div>
<div className={subCls}>
{shareQueueBusy
? sub(t('search.shareQueueing'))
: sub(`${shareTrackSong.artist}${shareTrackSong.album ? ` · ${shareTrackSong.album}` : ''}`)}
</div>
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareTrackUnavailable ? t('sharePaste.trackUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue') {
const count = sharePayloadTotal(shareMatch.payload);
const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row';
const handleModalEnqueue = () => {
void Promise.resolve(onEnqueue()).then(ok => {
if (ok) setQueuePreviewOpen(false);
});
};
return (
<>
{wrap(
<div
className={`${rowCls}${activeIndex === 0 ? ' active' : ''}`}
role={desktop ? 'option' : undefined}
>
<button
type="button"
className={desktop ? 'search-share-queue-main' : 'mobile-search-item search-share-queue-main'}
onClick={() => void onEnqueue()}
disabled={shareQueueBusy}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<StaticIcon className={iconCls}><ListPlus size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('search.shareQueueTitle', { count })}</div>
<div className={subCls}>
{shareQueueBusy ? sub(t('search.shareQueueing')) : sub(t('search.shareQueueAction'))}
</div>
</div>
</button>
<button
type="button"
className="search-share-queue-preview-btn"
onClick={e => {
e.stopPropagation();
setQueuePreviewOpen(true);
}}
aria-label={t('search.shareQueuePreview')}
>
<Eye size={desktop ? 16 : 18} />
</button>
</div>,
)}
<ShareQueuePreviewModal
open={queuePreviewOpen}
onClose={() => setQueuePreviewOpen(false)}
payload={shareMatch.payload}
preview={queuePreview}
shareServerLabel={shareServerLabel}
coverServer={shareCoverServer}
onEnqueue={handleModalEnqueue}
enqueueBusy={shareQueueBusy}
/>
</>
);
}
return null;
}