mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
|
||||
import {
|
||||
confirmAddAllDuplicates,
|
||||
isSmartPlaylistName,
|
||||
} from '@/utils/componentHelpers/contextMenuHelpers';
|
||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
|
||||
interface Props {
|
||||
songIds: string[];
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
downloadAlbum as downloadAlbumAction,
|
||||
startInstantMix as startInstantMixAction,
|
||||
startRadio as startRadioAction,
|
||||
} from '@/utils/componentHelpers/contextMenuActions';
|
||||
} from '@/features/contextMenu/utils/contextMenuActions';
|
||||
import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
|
||||
import {
|
||||
confirmAddAllDuplicates,
|
||||
isSmartPlaylistName,
|
||||
} from '@/utils/componentHelpers/contextMenuHelpers';
|
||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
|
||||
interface Props {
|
||||
albumIds: string[];
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast } from '@/lib/dom/toast';
|
||||
import {
|
||||
confirmAddAllDuplicates,
|
||||
isSmartPlaylistName,
|
||||
} from '@/utils/componentHelpers/contextMenuHelpers';
|
||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
|
||||
interface Props {
|
||||
artistIds: string[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { isSmartPlaylistName } from '@/utils/componentHelpers/contextMenuHelpers';
|
||||
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
|
||||
interface SingleProps {
|
||||
playlist: { id: string; name: string };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useConfirmModalStore } from '@/store/confirmModalStore';
|
||||
|
||||
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
|
||||
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
|
||||
|
||||
export function isSmartPlaylistName(name: string | undefined | null): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX);
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
|
||||
export function shuffleArray<T>(arr: T[]): T[] {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Ask user before re-adding songs to a playlist when *all* selected ids are
|
||||
* already present. Returns true → caller should append them as duplicates,
|
||||
* false → caller should keep today's silent-skip toast behavior. */
|
||||
export async function confirmAddAllDuplicates(
|
||||
playlistName: string,
|
||||
count: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): Promise<boolean> {
|
||||
return useConfirmModalStore.getState().request({
|
||||
title: t('playlists.duplicateConfirmTitle'),
|
||||
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
|
||||
confirmLabel: t('playlists.duplicateConfirmAction'),
|
||||
cancelLabel: t('common.cancel'),
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square, X } from 'lucide-react';
|
||||
import type { ColDef } from '@/hooks/useTracklistColumns';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
|
||||
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, ExternalLink, Star } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { formatTotalDuration } from '@/utils/componentHelpers/nowPlayingHelpers';
|
||||
import { formatTotalDuration } from '@/features/nowPlaying/utils/nowPlayingHelpers';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
interface AlbumCardProps {
|
||||
|
||||
@@ -2,7 +2,8 @@ import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import type { SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
|
||||
import { isRealArtistImage, sanitizeHtml } from '@/utils/componentHelpers/nowPlayingHelpers';
|
||||
import { isRealArtistImage } from '@/cover/isRealArtistImage';
|
||||
import { sanitizeHtml } from '@/features/nowPlaying/utils/nowPlayingHelpers';
|
||||
import CachedImage from '@/ui/CachedImage';
|
||||
|
||||
export interface ArtistCardTab {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ContributorRow } from '@/utils/componentHelpers/nowPlayingHelpers';
|
||||
import type { ContributorRow } from '@/features/nowPlaying/utils/nowPlayingHelpers';
|
||||
|
||||
interface CreditsCardProps { rows: ContributorRow[]; }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Info } from 'lucide-react';
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||
import type { BandsintownEvent } from '@/api/bandsintown';
|
||||
import { isoToParts } from '@/utils/componentHelpers/nowPlayingHelpers';
|
||||
import { isoToParts } from '@/features/nowPlaying/utils/nowPlayingHelpers';
|
||||
|
||||
interface TourCardProps {
|
||||
artistName: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
|
||||
import {
|
||||
buildContributorRows,
|
||||
} from '@/utils/componentHelpers/nowPlayingHelpers';
|
||||
} from '@/features/nowPlaying/utils/nowPlayingHelpers';
|
||||
import NpCardWrap from '@/features/nowPlaying/components/NpCardWrap';
|
||||
import NpColumnEl from '@/features/nowPlaying/components/NpColumnEl';
|
||||
import RadioView from '@/features/nowPlaying/components/RadioView';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { sanitizeHtml as sanitizeHtmlBase } from '@/lib/util/sanitizeHtml';
|
||||
|
||||
export function formatTime(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatCompact(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function formatTotalDuration(s: number): string {
|
||||
if (!s || isNaN(s)) return '—';
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${sec}s`;
|
||||
return `${sec}s`;
|
||||
}
|
||||
|
||||
/** Shared HTML sanitiser plus a now-playing-specific tweak: strip the trailing
|
||||
* "Read more on Last.fm" style link so clamped bios end cleanly. */
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return sanitizeHtmlBase(html).replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
|
||||
}
|
||||
|
||||
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return {
|
||||
month: d.toLocaleString(undefined, { month: 'short' }),
|
||||
day: String(d.getDate()),
|
||||
weekday: d.toLocaleString(undefined, { weekday: 'short' }),
|
||||
time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContributorRow { role: string; names: string[]; }
|
||||
|
||||
export function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] {
|
||||
if (!song?.contributors || song.contributors.length === 0) return [];
|
||||
const mainLower = mainArtistName.trim().toLowerCase();
|
||||
const rows = new Map<string, Set<string>>();
|
||||
for (const c of song.contributors) {
|
||||
const role = c.role?.trim();
|
||||
const name = c.artist?.name?.trim();
|
||||
if (!role || !name) continue;
|
||||
const label = c.subRole ? `${role} • ${c.subRole}` : role;
|
||||
let bucket = rows.get(label);
|
||||
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
|
||||
bucket.add(name);
|
||||
}
|
||||
const out: ContributorRow[] = [];
|
||||
for (const [role, names] of rows.entries()) {
|
||||
const list = Array.from(names);
|
||||
if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue;
|
||||
out.push({ role, names: list });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
import { librarySqlServerId } from '@/api/coverCache';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
|
||||
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
|
||||
import {
|
||||
enqueueOfflinePin,
|
||||
registerOfflinePinExecutor,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
syncAllPinnedPlaylists,
|
||||
syncPinnedArtistIfNeeded,
|
||||
} from '@/features/offline/utils/pinnedOfflineSync';
|
||||
import { SMART_PREFIX } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { SMART_PREFIX } from '@/lib/format/playlistDetailHelpers';
|
||||
|
||||
const getPlaylistMock = vi.fn();
|
||||
const getAlbumForServerMock = vi.fn();
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PinSource } from '@/store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
|
||||
import { getMediaDir } from '@/lib/media/mediaDir';
|
||||
import {
|
||||
isActiveServerReachable,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/features/playlist/store/playlistLayoutStore';
|
||||
import {
|
||||
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
|
||||
} from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
} from '@/lib/format/playlistDetailHelpers';
|
||||
import type { CoverArtId } from '@/cover/types';
|
||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '@/features/playlist/hooks/usePlaylistCovers';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Heart, Play, Square, Trash2 } from 'lucide-react';
|
||||
import type { ColDef } from '@/hooks/useTracklistColumns';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
|
||||
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { usePlaylistLayoutStore } from '@/features/playlist/store/playlistLayoutStore';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
|
||||
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
|
||||
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from '@tauri-apps/api/path';
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
import { useZipDownloadStore } from '@/features/offline';
|
||||
import { sanitizeFilename } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { sanitizeFilename } from '@/lib/format/playlistDetailHelpers';
|
||||
|
||||
export interface RunPlaylistZipDownloadDeps {
|
||||
playlist: SubsonicPlaylist;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { PlaybackSourceKind } from '@/features/playback/utils/playback/reso
|
||||
import {
|
||||
formatQueueReplayGainParts,
|
||||
renderStars,
|
||||
} from '@/utils/componentHelpers/queuePanelHelpers';
|
||||
} from '@/features/queue/utils/queuePanelHelpers';
|
||||
import { loudnessGainPlaceholderUntilCacheDb } from '@/features/playback/utils/audio/loudnessPlaceholder';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/lib/audio/loudnessPreAnalysisSlider';
|
||||
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/lib/library/trackEnrichment';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
|
||||
import type { DurationMode } from '@/features/queue/utils/queuePanelHelpers';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
import { formatClockTime } from '@/lib/format/formatClockTime';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
|
||||
export type { DurationMode } from '@/store/authStoreTypes';
|
||||
|
||||
export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
|
||||
const parts: string[] = [];
|
||||
const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
|
||||
if (track.replayGainTrackDb != null) {
|
||||
parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
|
||||
}
|
||||
if (track.replayGainAlbumDb != null) {
|
||||
parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
|
||||
}
|
||||
if (track.replayGainPeak != null) {
|
||||
parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function renderStars(rating?: number) {
|
||||
if (!rating) return null;
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<Star
|
||||
key={i}
|
||||
size={12}
|
||||
fill={i <= rating ? 'var(--highlight)' : 'none'}
|
||||
color={i <= rating ? 'var(--highlight)' : 'var(--text-muted)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>{stars}</div>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '@/features/artist';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
|
||||
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { LyricsSourceId } from '@/store/authStoreTypes';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
|
||||
import { useQueueToolbarStore, QueueToolbarButtonId } from '@/store/queueToolbarStore';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
|
||||
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
import { AddServerForm } from '@/features/settings/components/AddServerForm';
|
||||
import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { tooltipAttrs } from '@/ui/tooltipAttrs';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLuckyMixAvailable } from '@/features/randomMix';
|
||||
import { ALL_NAV_ITEMS } from '@/config/navItems';
|
||||
import { applySidebarReorderById } from '@/features/sidebar';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import type { ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@/lib/themes/themeRegistry';
|
||||
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
|
||||
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
|
||||
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
|
||||
import { isNewer } from '@/lib/util/appUpdaterHelpers';
|
||||
|
||||
type ModeFilter = 'all' | 'dark' | 'light';
|
||||
type SortMode = 'newest' | 'name';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
|
||||
import { isNewer } from '@/lib/util/appUpdaterHelpers';
|
||||
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/lib/themes/themeRegistry';
|
||||
import { useInstalledThemesStore } from '@/store/installedThemesStore';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ALL_NAV_ITEMS } from '@/config/navItems';
|
||||
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
|
||||
export type SidebarNavSection = 'library' | 'system';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '@/lib/util/platform';
|
||||
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/utils/componentHelpers/appUpdaterHelpers';
|
||||
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/lib/util/appUpdaterHelpers';
|
||||
|
||||
/** All update-modal state, the GitHub release probe and the download/relaunch
|
||||
* handlers. The component owns only the early-return guard and the JSX. */
|
||||
|
||||
Reference in New Issue
Block a user