mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
refactor(utils): split componentHelpers grab-bag to owning layers
Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
move would rewrite a do-not-touch file (same block as licensesData).
Pure moves; tests pass unmodified.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { useZipDownloadStore } from '@/features/offline';
|
||||
import { useDownloadModalStore } from '@/features/offline';
|
||||
import type { EntityShareKind } from '@/utils/share/shareLink';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { showToast } from '@/lib/dom/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 { queueItems, queueIndex } = usePlayerStore.getState();
|
||||
// Thin-state: resolve the upcoming radio refs (cache-warm window) back to
|
||||
// Tracks so they merge with the new similars in enqueueRadio.
|
||||
const pendingRadio = queueItems
|
||||
.slice(queueIndex + 1)
|
||||
.filter(r => r.radioAdded)
|
||||
.map(r => resolveQueueTrack(r));
|
||||
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 fetchSimilarTracksRouted(song.id, 50);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const ratedFiltered = await filterSongsForLuckyMixRatings(
|
||||
similar.filter(s => s.id !== song.id),
|
||||
mixCfg,
|
||||
);
|
||||
const shuffled = shuffleArray(
|
||||
ratedFiltered.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user