Files
Psychotoxical-psysonic/src/utils/contextMenuActions.ts
T
Frank Stellmacher ef5eda263d refactor(context-menu): H.1–H.12 — extract submenus + 5 type-branch components + hooks (Phase H start) (#662)
* refactor(context-menu): H.1 — extract helpers + constants

* refactor(context-menu): H.2 — extract AddToPlaylistSubmenu component

* refactor(context-menu): H.3 — extract AlbumToPlaylistSubmenu + ArtistToPlaylistSubmenu

* refactor(context-menu): H.4 — extract MultiAlbumToPlaylistSubmenu

* refactor(context-menu): H.5 — extract MultiArtistToPlaylistSubmenu

* refactor(context-menu): H.6 — extract SinglePlaylist + MultiPlaylist submenus

* refactor(context-menu): H.7 — extract startRadio/startInstantMix/downloadAlbum/copyShareLink actions

* refactor(context-menu): H.8 — extract useContextMenuKeyboardNav hook

* refactor(context-menu): H.9 — extract useContextMenuRating hook

* refactor(context-menu): H.10 — extract ContextMenuItems (all 9 type branches)

* refactor(context-menu): H.11 — split ContextMenuItems into 5 type-branch files

ContextMenuItems.tsx (800 LOC) was just a moved 400-LOC-cap violation.
Now ContextMenuItems is a 30-LOC switch that dispatches to:
  - SongContextItems       (song + album-song + favorite-song)
  - QueueItemContextItems  (queue-item)
  - AlbumContextItems      (album + multi-album)
  - ArtistContextItems     (artist + multi-artist)
  - PlaylistContextItems   (playlist + multi-playlist)

All five branch files are now under 330 LOC; ContextMenu.tsx itself stays
at 194 LOC. Shared Props interface lives in contextMenuItemTypes.ts.

* refactor(context-menu): H.12 — strip unused imports from branch components
2026-05-13 21:06:49 +02:00

147 lines
5.3 KiB
TypeScript

import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { getSimilarSongs2, getSimilarSongs, getTopSongs } from '../api/subsonicArtists';
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { Track } from '../store/playerStoreTypes';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import type { EntityShareKind } from './shareLink';
import { copyEntityShareLink } from './copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from './contextMenuHelpers';
import { songToTrack } from './songToTrack';
import { showToast } from './toast';
export async function copyShareLink(
kind: EntityShareKind,
id: string,
t: (key: string) => string,
) {
const ok = await copyEntityShareLink(kind, id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
export async function startRadio(
artistId: string,
artistName: string,
playTrack: (track: Track, queue: Track[]) => void,
seedTrack?: Track,
) {
if (seedTrack) {
const state = usePlayerStore.getState();
if (state.currentTrack?.id === seedTrack.id) {
if (!state.isPlaying) state.resume();
} else {
playTrack(seedTrack, [seedTrack]);
}
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
);
const radioTracks = similarTracks.length > 0
? similarTracks
: shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
);
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) {
console.error('Failed to load radio queue', e);
}
return;
}
// Artist radio without seed
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
try {
const top = await getTopSongs(artistName);
const topTracks = shuffleArray(
top.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
if (topTracks.length === 0) {
const similar = await similarPromise;
const fallback = shuffleArray(
similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
if (fallback.length === 0) return;
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio(fallback, artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(fallback[0], fallback);
}
return;
}
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio([topTracks[0]], artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(topTracks[0], [topTracks[0]]);
}
similarPromise.then(similar => {
const similarTracks = shuffleArray(
similar
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
.filter(t => t.id !== topTracks[0].id),
);
if (similarTracks.length === 0) return;
const { queue, queueIndex } = usePlayerStore.getState();
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
});
} catch (e) {
console.error('Failed to start radio', e);
}
}
export async function startInstantMix(
song: Track,
t: (key: string) => string,
) {
usePlayerStore.getState().reseedQueueForInstantMix(song);
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const shuffled = shuffleArray(
similar
.filter(s => s.id !== song.id)
.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
} catch (e) {
console.error('Instant mix failed', e);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
}
}
export async function downloadAlbum(albumName: string, albumId: string) {
const auth = useAuthStore.getState();
const requestDownloadFolder = useDownloadModalStore.getState().requestFolder;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(albumName)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const id = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(id, filename);
try {
await invoke('download_zip', { id, url, destPath });
complete(id);
} catch (e) {
fail(id);
console.error('ZIP download failed:', e);
}
}