mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
209dd61442
Move domain-agnostic, feature-free helpers out of the flat utils/ and store/ roots into src/lib/ (plan M4, §3 lib/ layer): - lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration, relativeTime (pure format/date/byte/clock helpers) - lib/i18n.ts: i18next bootstrap (global app infra) - lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers + the zustand storage adapter) playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep = audio-core coupling, not generic). formatClockTime keeps a type-only @/store/authStoreTypes import (erased, no runtime inversion — accepted per the deviceSync precedent). Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface). tsc 0, lint 0/0, full suite 319 files / 2353 tests green. Tooling note: lib_move.py regex extended to also rewrite side-effect imports (import './i18n') and vi.importActual paths — both were silent gaps.
150 lines
5.9 KiB
TypeScript
150 lines
5.9 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Check, ListPlus, X } from 'lucide-react';
|
|
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import { usePlayerStore } from '@/store/playerStore';
|
|
import { songToTrack } from '@/utils/playback/songToTrack';
|
|
import { formatTrackTime } from '@/lib/format/formatDuration';
|
|
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
|
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
|
|
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
|
|
|
|
function PlaylistSearchResultThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
|
|
return (
|
|
<AlbumCoverArtImage
|
|
albumId={albumId}
|
|
coverArt={coverArt}
|
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
|
surface="dense"
|
|
alt=""
|
|
className="playlist-search-thumb"
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
query: string;
|
|
setQuery: (v: string) => void;
|
|
searching: boolean;
|
|
searchResults: SubsonicSong[];
|
|
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
|
selectedSearchIds: Set<string>;
|
|
setSelectedSearchIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
searchPlPickerOpen: boolean;
|
|
setSearchPlPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
contextMenuSongId: string | null;
|
|
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
addSong: (song: SubsonicSong) => void;
|
|
}
|
|
|
|
export default function PlaylistSongSearchPanel({
|
|
query, setQuery, searching, searchResults, setSearchResults,
|
|
selectedSearchIds, setSelectedSearchIds,
|
|
searchPlPickerOpen, setSearchPlPickerOpen,
|
|
contextMenuSongId, setContextMenuSongId,
|
|
addSong,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
|
|
|
return (
|
|
<div className="playlist-search-panel">
|
|
<div className="playlist-search-input-wrap">
|
|
<input
|
|
className="input"
|
|
placeholder={t('playlists.searchPlaceholder')}
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
{query && (
|
|
<button className="live-search-clear" onClick={() => { setQuery(''); setSearchResults([]); }}>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{searching && <div style={{ textAlign: 'center', padding: '0.75rem' }}><div className="spinner" /></div>}
|
|
{!searching && query && searchResults.length === 0 && (
|
|
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
|
)}
|
|
{selectedSearchIds.size > 0 && (
|
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.75rem', background: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderRadius: 'var(--radius-sm)', margin: '0.25rem 0' }}>
|
|
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, flex: 1 }}>
|
|
{t('common.bulkSelected', { count: selectedSearchIds.size })}
|
|
</span>
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
style={{ fontSize: 12 }}
|
|
onClick={() => setSelectedSearchIds(new Set())}
|
|
>
|
|
{t('common.clearSelection')}
|
|
</button>
|
|
<div style={{ position: 'relative' }}>
|
|
<button
|
|
className="btn btn-sm btn-primary"
|
|
style={{ fontSize: 12 }}
|
|
onClick={() => setSearchPlPickerOpen(v => !v)}
|
|
>
|
|
<ListPlus size={13} /> {t('contextMenu.addToPlaylist')}
|
|
</button>
|
|
{searchPlPickerOpen && (
|
|
<AddToPlaylistSubmenu
|
|
songIds={[...selectedSearchIds]}
|
|
dropDown
|
|
onDone={() => { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="btn btn-sm btn-primary"
|
|
style={{ fontSize: 12 }}
|
|
onClick={() => {
|
|
searchResults
|
|
.filter(s => selectedSearchIds.has(s.id))
|
|
.forEach(s => addSong(s));
|
|
setSelectedSearchIds(new Set());
|
|
}}
|
|
>
|
|
<Check size={13} /> {t('playlists.addSelected')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{searchResults.map(song => {
|
|
const isSelected = selectedSearchIds.has(song.id);
|
|
return (
|
|
<div
|
|
key={song.id}
|
|
className={`playlist-search-row${isSelected ? ' playlist-search-row--selected' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => addSong(song)}
|
|
onContextMenu={e => {
|
|
e.preventDefault();
|
|
setContextMenuSongId(song.id);
|
|
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="playlist-search-checkbox"
|
|
checked={isSelected}
|
|
onClick={e => e.stopPropagation()}
|
|
onChange={() => setSelectedSearchIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(song.id)) next.delete(song.id);
|
|
else next.add(song.id);
|
|
return next;
|
|
})}
|
|
/>
|
|
<PlaylistSearchResultThumb albumId={song.albumId} coverArt={song.coverArt ?? ''} />
|
|
<div className="playlist-search-info">
|
|
<span className="playlist-search-title">{song.title}</span>
|
|
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
|
</div>
|
|
<span className="playlist-search-duration">{formatTrackTime(song.duration ?? 0)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|