Files
Psychotoxical-psysonic/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx
T
Psychotoxical 8cc022581f refactor(api): pull feature-resident subsonic clients into lib/api
Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.

This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.

Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
2026-06-30 08:02:19 +02:00

205 lines
8.2 KiB
TypeScript

import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '../../utils/ui/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '../../utils/componentHelpers/contextMenuHelpers';
interface Props {
albumIds: string[];
onDone: () => void;
triggerId?: string;
}
export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _triggerId }: Props) {
const { t } = useTranslation();
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
const [totalAlbums, setTotalAlbums] = useState(0);
const [showLoading, setShowLoading] = useState(false);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalAlbums(albumIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const serverId = resolveMediaServerId();
const albumSongs = serverId
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
: [];
const allSongs = albumSongs.flat();
setResolvedIds(allSongs.map(s => s.id));
})().catch(() => setResolvedIds([]));
return () => clearTimeout(loadingTimeout);
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
const duplicateIds: string[] = [];
for (const id of songIds) {
if (existingIds.has(id)) duplicateIds.push(id);
else newIds.push(id);
}
const addedCount = newIds.length;
const duplicateCount = duplicateIds.length;
if (addedCount > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
if (duplicateCount > 0) {
showToast(t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), 4000, 'info');
} else {
showToast(t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), 3000, 'info');
}
} else if (duplicateCount > 0) {
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
if (accepted) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
touchPlaylist(pl.id);
showToast(t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }), 3000, 'info');
} else {
showToast(t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), 4000, 'info');
}
}
} catch {
showToast(t('playlists.addError'), 4000, 'error');
}
onDone();
};
// Custom AddToPlaylistSubmenu with toast notifications for multiple albums
function MultiAddToPlaylistSubmenu({ songIds, onDone: innerOnDone }: { songIds: string[]; onDone: () => void }) {
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const [visible, setVisible] = useState(false);
const storePlaylists = usePlaylistStore((s) => s.playlists);
const playlists = useMemo(() => {
return [...storePlaylists]
.filter(p => !isSmartPlaylistName(p.name))
.sort((a, b) => a.name.localeCompare(b.name));
}, [storePlaylists]);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
if (rect.bottom > window.innerHeight - 8) setFlipUp(true);
setVisible(true);
}
}, []);
useEffect(() => {
if (creating) newNameRef.current?.focus();
}, [creating]);
const handleAdd = async (pl: SubsonicPlaylist) => {
setAdding(pl.id);
await handleAddWithToast(pl, songIds);
setAdding(null);
};
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const { createPlaylist } = await import('@/lib/api/subsonicPlaylists');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
usePlaylistStore.getState().touchPlaylist(pl.id);
showToast(t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }), 3000, 'info');
}
} catch {
showToast(t('playlists.createError'), 4000, 'error');
}
innerOnDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
return (
<div className="context-submenu" ref={subRef} style={{ ...subStyle, visibility: visible ? 'visible' : 'hidden' }}>
{!creating ? (
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
if (resolvedIds === null) {
if (!showLoading) {
return <div className="context-submenu" style={{ minWidth: 190 }} />;
}
return (
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem', minWidth: 190 }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('playlists.loadingAlbums', { count: totalAlbums })}
</span>
</div>
);
}
if (resolvedIds.length === 0) return null;
// React Compiler rule: component intentionally defined inline for closure access.
// eslint-disable-next-line react-hooks/static-components
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}