import React, { useEffect, useState } from 'react'; import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import type { SubsonicArtist } 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 '@/features/search/hooks/useShareSearchPreview'; import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage'; import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage'; import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage'; import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes'; import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types'; import { useShareQueuePreview } from '@/features/search/hooks/useShareQueuePreview'; import ShareQueuePreviewModal from '@/features/search/components/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; onOpenAlbum: () => void; onOpenArtist: () => void; onOpenComposer: () => void; onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void; } & ShareSearchPreviewState; function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope { if (coverServer) { return { kind: 'server', serverId: coverServer.id, url: coverServer.url, username: coverServer.username, password: coverServer.password, }; } return COVER_SCOPE_ACTIVE; } function ShareAlbumThumb({ albumId, coverArt, displayCssPx, coverServer, }: { albumId: string; coverArt: string; displayCssPx: number; coverServer?: ServerProfile | null; }) { const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb'; return ( ); } function ShareArtistThumb({ artist, displayCssPx, coverServer, }: { artist: Pick; displayCssPx: number; coverServer?: ServerProfile | null; }) { const [failed, setFailed] = useState(false); // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]); if (failed) { if (displayCssPx >= 64) { return (
); } return (
); } const cls = displayCssPx >= 64 ? 'mobile-search-thumb mobile-search-thumb--artist-round' : 'search-result-thumb'; return ( setFailed(true)} /> ); } function StaticIcon({ className, children }: { className: string; children: React.ReactNode }) { return
{children}
; } 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 thumbDisplayCssPx = desktop ? COVER_DENSE_SEARCH_CSS_PX : 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) => (
{desktop && } {t('search.shareLink')}
{content}
); 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 = (
{t('search.shareUnsupportedTitle')}
{t('search.shareUnsupportedSub')}
); if (shareMatch.type === 'unsupported') { return wrap(unsupportedRow); } if (shareMatch.type === 'artist') { if (shareArtistResolving) { return wrap(
{t('common.loading')}
{sub(t('search.artists'))}
, ); } if (shareArtist) { return wrap( , ); } return wrap(
{shareArtistUnavailable ? t('sharePaste.artistUnavailable') : t('sharePaste.genericError')}
{t('search.shareUnsupportedSub')}
, ); } if (shareMatch.type === 'composer') { if (shareComposerResolving) { return wrap(
{t('common.loading')}
{sub(t('sidebar.composers'))}
, ); } if (shareComposer) { return wrap( , ); } return wrap(
{shareComposerUnavailable ? t('sharePaste.composerUnavailable') : t('sharePaste.genericError')}
{t('search.shareUnsupportedSub')}
, ); } if (shareMatch.type === 'album') { if (shareAlbumResolving) { return wrap(
{t('common.loading')}
{sub(t('search.album'))}
, ); } if (shareAlbum) { return wrap( , ); } return wrap(
{shareAlbumUnavailable ? t('sharePaste.albumUnavailable') : t('sharePaste.genericError')}
{t('search.shareUnsupportedSub')}
, ); } if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'track') { if (shareTrackResolving) { return wrap(
{t('common.loading')}
{sub(t('search.shareTrackTitle'))}
, ); } if (shareTrackSong) { return wrap( , ); } return wrap(
{shareTrackUnavailable ? t('sharePaste.trackUnavailable') : t('sharePaste.genericError')}
{t('search.shareUnsupportedSub')}
, ); } 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(
, )} setQueuePreviewOpen(false)} payload={shareMatch.payload} preview={queuePreview} shareServerLabel={shareServerLabel} coverServer={shareCoverServer} onEnqueue={handleModalEnqueue} enqueueBusy={shareQueueBusy} /> ); } return null; }