mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -10,6 +10,8 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -67,6 +69,13 @@ export default function LiveSearch() {
|
||||
const compactHeaderControlsRef = useRef(false);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}, []);
|
||||
|
||||
const share = useShareSearch(query, closeSearch);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setOpen(false); return; }
|
||||
@@ -82,7 +91,17 @@ export default function LiveSearch() {
|
||||
[musicLibraryFilterVersion]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||
useEffect(() => {
|
||||
if (share.shareMatch) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
doSearch(query);
|
||||
setActiveIndex(-1);
|
||||
}, [query, doSearch, share.shareMatch]);
|
||||
|
||||
const isSearchActive = isFocused || open || query.trim().length > 0;
|
||||
|
||||
@@ -202,10 +221,22 @@ export default function LiveSearch() {
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [ctxIsOpen]);
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
const hasResults =
|
||||
!!share.shareMatch ||
|
||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||
|
||||
// Flat list of all navigable items for keyboard nav
|
||||
const flatItems = results ? [
|
||||
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
||||
{
|
||||
id: 'share-link',
|
||||
action: () => {
|
||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||
},
|
||||
},
|
||||
] : results ? [
|
||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||
@@ -217,6 +248,22 @@ export default function LiveSearch() {
|
||||
] : [];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (share.shareMatch) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!open || !flatItems.length) {
|
||||
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
return;
|
||||
@@ -312,7 +359,36 @@ export default function LiveSearch() {
|
||||
<div className="search-empty">{t('search.noResults', { query })}</div>
|
||||
)}
|
||||
|
||||
{share.shareMatch && (
|
||||
<ShareSearchResults
|
||||
variant="desktop"
|
||||
shareMatch={share.shareMatch}
|
||||
shareServerLabel={share.shareServerLabel}
|
||||
shareCoverServer={share.shareCoverServer}
|
||||
activeIndex={activeIndex}
|
||||
shareQueueBusy={share.shareQueueBusy}
|
||||
onEnqueue={() => void share.enqueueShareMatch()}
|
||||
onOpenAlbum={share.openShareAlbum}
|
||||
onOpenArtist={share.openShareArtist}
|
||||
onOpenComposer={share.openShareComposer}
|
||||
onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)}
|
||||
shareTrackSong={share.shareTrackSong}
|
||||
shareTrackResolving={share.shareTrackResolving}
|
||||
shareTrackUnavailable={share.shareTrackUnavailable}
|
||||
shareAlbum={share.shareAlbum}
|
||||
shareAlbumResolving={share.shareAlbumResolving}
|
||||
shareAlbumUnavailable={share.shareAlbumUnavailable}
|
||||
shareArtist={share.shareArtist}
|
||||
shareArtistResolving={share.shareArtistResolving}
|
||||
shareArtistUnavailable={share.shareArtistUnavailable}
|
||||
shareComposer={share.shareComposer}
|
||||
shareComposerResolving={share.shareComposerResolving}
|
||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
if (share.shareMatch) return null;
|
||||
let idx = 0;
|
||||
return <>
|
||||
{results?.artists.length ? (
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
@@ -67,6 +69,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const share = useShareSearch(query, onClose);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
@@ -86,7 +89,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
[musicLibraryFilterVersion]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
useEffect(() => {
|
||||
if (share.shareMatch) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
doSearch(query);
|
||||
}, [query, doSearch, share.shareMatch]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
@@ -114,7 +124,9 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
});
|
||||
};
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
const hasResults =
|
||||
!!share.shareMatch ||
|
||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||
const showEmpty = !query;
|
||||
|
||||
return createPortal(
|
||||
@@ -209,8 +221,34 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share.shareMatch && (
|
||||
<ShareSearchResults
|
||||
variant="mobile"
|
||||
shareMatch={share.shareMatch}
|
||||
shareServerLabel={share.shareServerLabel}
|
||||
shareCoverServer={share.shareCoverServer}
|
||||
shareQueueBusy={share.shareQueueBusy}
|
||||
onEnqueue={() => void share.enqueueShareMatch()}
|
||||
onOpenAlbum={share.openShareAlbum}
|
||||
onOpenArtist={share.openShareArtist}
|
||||
onOpenComposer={share.openShareComposer}
|
||||
shareTrackSong={share.shareTrackSong}
|
||||
shareTrackResolving={share.shareTrackResolving}
|
||||
shareTrackUnavailable={share.shareTrackUnavailable}
|
||||
shareAlbum={share.shareAlbum}
|
||||
shareAlbumResolving={share.shareAlbumResolving}
|
||||
shareAlbumUnavailable={share.shareAlbumUnavailable}
|
||||
shareArtist={share.shareArtist}
|
||||
shareArtistResolving={share.shareArtistResolving}
|
||||
shareArtistUnavailable={share.shareArtistUnavailable}
|
||||
shareComposer={share.shareComposer}
|
||||
shareComposerResolving={share.shareComposerResolving}
|
||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
{hasResults && (
|
||||
{hasResults && !share.shareMatch && (
|
||||
<>
|
||||
{results!.artists.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user