mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
7e9cb60763
Move the Subsonic + Navidrome protocol clients and the library IPC facade (subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/, the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/). Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150 consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis infra stay in src/api/ pending their own M4 placement. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
227 lines
7.2 KiB
TypeScript
227 lines
7.2 KiB
TypeScript
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 '@/lib/api/subsonicTypes';
|
|
import type { ServerProfile } from '@/store/authStoreTypes';
|
|
import { formatTrackTime } from '@/utils/format/formatDuration';
|
|
import type { ShareQueuePreviewState } from '@/features/search/hooks/useShareQueuePreview';
|
|
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/utils/share/shareSearch';
|
|
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
|
import { usePlayerStore } from '@/store/playerStore';
|
|
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
|
|
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
|
|
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
|
|
|
type ShareQueuePreviewModalProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }>;
|
|
preview: ShareQueuePreviewState;
|
|
shareServerLabel?: string | null;
|
|
coverServer?: ServerProfile | null;
|
|
onEnqueue: () => void;
|
|
enqueueBusy: boolean;
|
|
confirmLabel?: string;
|
|
confirmBusyLabel?: string;
|
|
};
|
|
|
|
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 QueuePreviewTrackRow({
|
|
song,
|
|
coverServer,
|
|
}: {
|
|
song: SubsonicSong;
|
|
coverServer?: ServerProfile | null;
|
|
}) {
|
|
const coverId = song.coverArt || song.id;
|
|
|
|
return (
|
|
<li className="share-queue-preview-track">
|
|
{song.coverArt ? (
|
|
<AlbumCoverArtImage
|
|
albumId={song.albumId ?? coverId}
|
|
coverArt={song.coverArt}
|
|
serverScope={shareCoverServerScope(coverServer)}
|
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
|
surface="dense"
|
|
className="share-queue-preview-track__thumb"
|
|
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>
|
|
)}
|
|
<OverlayScrollArea
|
|
className="share-queue-preview-modal__list-wrap"
|
|
viewportClassName="share-queue-preview-modal__list-viewport"
|
|
measureDeps={[preview.songs.length, preview.skipped]}
|
|
railInset="panel"
|
|
>
|
|
<ul className="share-queue-preview-modal__list">
|
|
{preview.songs.map(song => (
|
|
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
|
|
))}
|
|
</ul>
|
|
</OverlayScrollArea>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ShareQueuePreviewModal({
|
|
open,
|
|
onClose,
|
|
payload,
|
|
preview,
|
|
shareServerLabel,
|
|
coverServer,
|
|
onEnqueue,
|
|
enqueueBusy,
|
|
confirmLabel,
|
|
confirmBusyLabel,
|
|
}: ShareQueuePreviewModalProps) {
|
|
const { t } = useTranslation();
|
|
const actionLabel = confirmLabel ?? t('search.shareQueueAction');
|
|
const actionBusyLabel = confirmBusyLabel ?? t('search.shareQueueing');
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
usePlayerStore.getState().closeContextMenu();
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
const blockContextMenu = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
usePlayerStore.getState().closeContextMenu();
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
document.addEventListener('contextmenu', blockContextMenu, true);
|
|
return () => {
|
|
document.removeEventListener('keydown', handler);
|
|
document.removeEventListener('contextmenu', blockContextMenu, true);
|
|
};
|
|
}, [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"
|
|
onContextMenu={e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
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"
|
|
onContextMenu={e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
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 ? actionBusyLabel : actionLabel}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|