mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio, cache, cover, share, server, playback, playlist, deviceSync, waveform, mix, format, export, changelog, ui, perf, componentHelpers). True singletons with no cluster stay at the utils/ root. Pure file-move: a path-aware codemod rewrote 539 relative-import specifiers across 275 files; no logic touched. The hot-path coverage gate list (.github/frontend-hot-path-files.txt) is updated to the new paths for the 11 gated utils files — a mechanical consequence of the move, not a CI change. tsc is green.
This commit is contained in:
committed by
GitHub
parent
2409a1fec8
commit
7a7a9f5e6b
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Make an arbitrary album / playlist name safe to use as a file name on
|
||||
* Windows, macOS, and Linux. Replaces every reserved character class with
|
||||
* a dash, collapses runs of dots (which Windows treats specially) into one,
|
||||
* trims leading/trailing whitespace and dots, and caps the length at 200
|
||||
* characters so we don't hit MAX_PATH edges on Windows. Falls back to
|
||||
* `download` when the sanitisation strips the name down to nothing.
|
||||
*/
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ColDef } from '../useTracklistColumns';
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export const COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
export const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
||||
|
||||
export const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
||||
|
||||
export function isSortable(key: ColKey | string): key is SortKey {
|
||||
return SORTABLE_COLS.has(key as ColKey);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
|
||||
|
||||
export function readInitialSidebarCollapsed(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSidebarCollapsed(collapsed: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||
} catch {
|
||||
// Ignore storage failures and keep in-memory UI state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid grabbing the queue resizer when aiming at the main overlay scrollbar.
|
||||
* Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that).
|
||||
* Only the main-route thumb counts (not queue/mini/sidebar thumbs — selector is scoped).
|
||||
*
|
||||
* The queue resizer is 6px and sits on the main|queue seam with ~3px overlapping the main
|
||||
* column (layout.css `.resizer-queue`). Treating `clientX <= mainRight` as "main" suppressed
|
||||
* that overlap and felt like a dead resize strip at certain widths. Thumb hit slop must not
|
||||
* extend past `mainRight` or it steals grabs on the resizer.
|
||||
*/
|
||||
export function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean {
|
||||
const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null;
|
||||
const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth;
|
||||
/** Pixels of the resizer that lie left of the main column's right edge (see `.resizer-queue`). */
|
||||
const RESIZER_BLEED_INTO_MAIN = 4;
|
||||
|
||||
if (clientX <= mainRight - RESIZER_BLEED_INTO_MAIN) return true;
|
||||
|
||||
const thumbs = document.querySelectorAll<HTMLElement>('.app-shell-route-scroll .overlay-scroll__thumb');
|
||||
const xSlop = 22;
|
||||
const vPad = 40;
|
||||
for (let i = 0; i < thumbs.length; i++) {
|
||||
const thumb = thumbs[i];
|
||||
const style = window.getComputedStyle(thumb);
|
||||
const pointerActive = style.pointerEvents !== 'none';
|
||||
const visible = Number.parseFloat(style.opacity || '0') > 0.01;
|
||||
if (!pointerActive && !visible) continue;
|
||||
|
||||
const r = thumb.getBoundingClientRect();
|
||||
if (r.height < 4 || r.width < 1) continue;
|
||||
if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue;
|
||||
const thumbHitRight = Math.min(r.right + xSlop, mainRight);
|
||||
if (clientX >= r.left - 6 && clientX <= thumbHitRight) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../platform';
|
||||
|
||||
export const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
export function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface GithubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ReleaseData {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
export type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||
|
||||
export function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||
if (IS_WINDOWS) {
|
||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||
?? assets.find(a => a.name.endsWith('.exe'));
|
||||
}
|
||||
if (IS_MACOS) {
|
||||
// Prefer Apple Silicon, fall back to Intel
|
||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||
}
|
||||
if (IS_LINUX) {
|
||||
// AppImage > deb > rpm
|
||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||
?? assets.find(a => a.name.endsWith('.deb'))
|
||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Strip dangerous tags/attributes from server-provided HTML */
|
||||
export function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
|
||||
export const ALL_SENTINEL = 'ALL';
|
||||
export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
|
||||
/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
|
||||
export const ARTIST_LIST_LETTER_ROW_EST = 48;
|
||||
export const ARTIST_LIST_ROW_EST = 64;
|
||||
export const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
|
||||
|
||||
export type ArtistListFlatRow =
|
||||
| { kind: 'letter'; letter: string }
|
||||
| { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
|
||||
|
||||
// Catppuccin accent colors — one is picked deterministically from the artist name
|
||||
const CTP_COLORS = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
||||
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
||||
];
|
||||
|
||||
export function nameColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return CTP_COLORS[h % CTP_COLORS.length];
|
||||
}
|
||||
|
||||
export function nameInitial(name: string): string {
|
||||
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 '../share/shareLink';
|
||||
import { copyEntityShareLink } from '../share/copyEntityShareLink';
|
||||
import { sanitizeFilename, shuffleArray } from './contextMenuHelpers';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { showToast } from '../ui/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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicDirectoryEntry } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
export type ColumnKind = 'roots' | 'indexes' | 'directory';
|
||||
export type NavPos = { colIndex: number; rowIndex: number };
|
||||
|
||||
export type Column = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: SubsonicDirectoryEntry[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
kind: ColumnKind;
|
||||
};
|
||||
|
||||
/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */
|
||||
export function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null {
|
||||
if (!item.isDir) return null;
|
||||
const albumId = item.albumId ?? (item.album ? item.id : undefined);
|
||||
if (!albumId) return null;
|
||||
return {
|
||||
id: albumId,
|
||||
name: item.album ?? item.title,
|
||||
artist: item.artist ?? '',
|
||||
artistId: item.artistId ?? '',
|
||||
coverArt: item.coverArt,
|
||||
year: item.year,
|
||||
genre: item.genre,
|
||||
starred: item.starred,
|
||||
userRating: item.userRating,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function entryToTrack(e: SubsonicDirectoryEntry): Track {
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
artist: e.artist ?? '',
|
||||
album: e.album ?? '',
|
||||
albumId: e.albumId ?? '',
|
||||
artistId: e.artistId,
|
||||
coverArt: e.coverArt,
|
||||
duration: e.duration ?? 0,
|
||||
track: e.track,
|
||||
year: e.year,
|
||||
bitRate: e.bitRate,
|
||||
suffix: e.suffix,
|
||||
genre: e.genre,
|
||||
starred: e.starred,
|
||||
userRating: e.userRating,
|
||||
};
|
||||
}
|
||||
|
||||
export function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean {
|
||||
return (
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.code === 'ArrowUp' ||
|
||||
e.code === 'ArrowDown' ||
|
||||
e.code === 'ArrowLeft' ||
|
||||
e.code === 'ArrowRight'
|
||||
);
|
||||
}
|
||||
|
||||
/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */
|
||||
export function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean {
|
||||
const n = e.nativeEvent;
|
||||
if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true;
|
||||
if (typeof n.getModifierState === 'function') {
|
||||
return (
|
||||
n.getModifierState('Control') ||
|
||||
n.getModifierState('Alt') ||
|
||||
n.getModifierState('Shift') ||
|
||||
n.getModifierState('Meta') ||
|
||||
n.getModifierState('OS')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge';
|
||||
|
||||
export const COLLAPSED_SIZE = { w: 340, h: 260 };
|
||||
export const EXPANDED_SIZE = { w: 340, h: 500 };
|
||||
// Minimum window dimensions per state. When the queue is open the floor must
|
||||
// keep at least two queue rows visible; a stricter min would let the user
|
||||
// collapse the queue area to nothing while it's still toggled on.
|
||||
export const COLLAPSED_MIN = { w: 320, h: 240 };
|
||||
export const EXPANDED_MIN = { w: 320, h: 340 };
|
||||
|
||||
// Persist the expanded-window height so reopening the queue restores the
|
||||
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
||||
export const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
|
||||
export function readStoredExpandedHeight(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(EXPANDED_H_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
|
||||
}
|
||||
} catch {}
|
||||
return EXPANDED_SIZE.h;
|
||||
}
|
||||
|
||||
// Persist whether the queue panel was open so the next launch restores
|
||||
// the same state. Same scope as the height: localStorage of the mini
|
||||
// webview (shared across mini sessions, separate from the main store).
|
||||
export const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
|
||||
export function readQueueOpen(): boolean {
|
||||
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
export function toMini(t: any): MiniTrackInfo {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
coverArt: t.coverArt,
|
||||
duration: t.duration,
|
||||
starred: !!t.starred,
|
||||
year: t.year,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate from the persisted playerStore so initial paint shows real content
|
||||
* instead of "—" while we wait for the mini:sync event from the main window.
|
||||
* The persisted state covers the cold-start window (webview boot + bundle).
|
||||
*/
|
||||
export function initialSnapshot(): MiniSyncPayload {
|
||||
try {
|
||||
const s = usePlayerStore.getState();
|
||||
return {
|
||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||
queue: (s.queue ?? []).map(toMini),
|
||||
queueIndex: s.queueIndex ?? 0,
|
||||
isPlaying: s.isPlaying,
|
||||
volume: s.volume ?? 1,
|
||||
gaplessEnabled: false,
|
||||
crossfadeEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
isMobile: false,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
track: null, queue: [], queueIndex: 0, isPlaying: false,
|
||||
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
|
||||
infiniteQueueEnabled: false, isMobile: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function fmt(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Strip trailing "Read more on Last.fm" style links for cleaner clamped bios.
|
||||
return doc.body.innerHTML.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
|
||||
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
|
||||
* artist image exists. The placeholder MD5 is fixed and documented.
|
||||
*/
|
||||
export function isRealArtistImage(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { formatHumanHoursMinutes } from '../format/formatHumanDuration';
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
export const SMART_PREFIX = 'psy-smart-';
|
||||
|
||||
export function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
export function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
export type DurationMode = 'total' | 'remaining' | 'eta';
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>{stars}</div>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { passesMixMinRatings, type MixMinRatingsConfig } from '../mix/mixRatingFilter';
|
||||
|
||||
export const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
'audiobook', 'audio book', 'spoken word', 'spokenword',
|
||||
'podcast', 'kapitel', 'thriller', 'krimi', 'speech',
|
||||
'fantasy', 'comedy', 'literature',
|
||||
];
|
||||
|
||||
export function formatRandomMixDuration(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface FilterArgs {
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
mixRatingCfg: MixMinRatingsConfig;
|
||||
}
|
||||
|
||||
export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): SubsonicSong[] {
|
||||
const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args;
|
||||
return songs.filter(song => {
|
||||
if (!excludeAudiobooks) return true;
|
||||
const checkText = (text: string) => {
|
||||
const t = text.toLowerCase();
|
||||
if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
|
||||
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
|
||||
return false;
|
||||
};
|
||||
if (song.genre && checkText(song.genre)) return false;
|
||||
if (song.title && checkText(song.title)) return false;
|
||||
if (song.album && checkText(song.album)) return false;
|
||||
if (song.artist && checkText(song.artist)) return false;
|
||||
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { uploadArtistImage } from '../../api/subsonicPlaylists';
|
||||
import { setRating, star, unstar } from '../../api/subsonicStarRating';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { copyEntityShareLink } from '../share/copyEntityShareLink';
|
||||
import { invalidateCoverArt } from '../imageCache';
|
||||
import { showToast } from '../ui/toast';
|
||||
|
||||
export interface RunArtistEntityRatingDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
id: string | undefined;
|
||||
rating: number;
|
||||
artistEntityRatingSupport: string;
|
||||
activeServerId: string;
|
||||
t: TFunction;
|
||||
setArtistEntityRating: (v: number) => void;
|
||||
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||
}
|
||||
|
||||
export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise<void> {
|
||||
const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps;
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistToggleStarDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promise<void> {
|
||||
const { artist, isStarred, setIsStarred } = deps;
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(artist.id, 'artist');
|
||||
else await star(artist.id, 'artist');
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistShareDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export async function runArtistShare(deps: RunArtistShareDeps): Promise<void> {
|
||||
const { artist, t } = deps;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('artist', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistImageUploadDeps {
|
||||
e: React.ChangeEvent<HTMLInputElement>;
|
||||
artist: SubsonicArtist | null;
|
||||
t: TFunction;
|
||||
setUploading: (v: boolean) => void;
|
||||
setCoverRevision: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export async function runArtistImageUpload(deps: RunArtistImageUploadDeps): Promise<void> {
|
||||
const { e, artist, t, setUploading, setCoverRevision } = deps;
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !artist) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadArtistImage(artist.id, file);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
await invalidateCoverArt(coverId);
|
||||
// Also invalidate with bare artist.id in case coverArt differs
|
||||
if (artist.coverArt && artist.coverArt !== artist.id) {
|
||||
await invalidateCoverArt(artist.id);
|
||||
}
|
||||
setCoverRevision(r => r + 1);
|
||||
showToast(t('artistDetail.uploadImage'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
||||
4000,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
|
||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks(albums);
|
||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks(albums);
|
||||
if (tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
setRadioLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailStartRadio(deps: RunArtistDetailStartRadioDeps): Promise<void> {
|
||||
const { artist, t, setRadioLoading, playTrack, enqueue } = deps;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
// Fire both fetches in parallel
|
||||
const topPromise = getTopSongs(artist.name);
|
||||
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||
|
||||
// Start playing as soon as top songs arrive
|
||||
const top = await topPromise;
|
||||
if (top.length > 0) {
|
||||
const firstTrack = songToTrack(top[0]);
|
||||
playTrack(firstTrack, [firstTrack]);
|
||||
setRadioLoading(false);
|
||||
// Enqueue remaining tracks when similar songs arrive
|
||||
const similar = await similarPromise;
|
||||
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||
if (remaining.length > 0) enqueue(remaining);
|
||||
} else {
|
||||
// No top songs — fall back to similar
|
||||
const similar = await similarPromise;
|
||||
if (similar.length > 0) {
|
||||
const tracks = similar.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
setRadioLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Radio start failed', e);
|
||||
setRadioLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
||||
export const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
||||
export const SMART_PREFIX = 'psy-smart-';
|
||||
export const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1';
|
||||
export const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
|
||||
export const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
|
||||
export const NEW_RELEASES_RESET_DELAY_MS = 5_000;
|
||||
/** Max album ids persisted per server/scope; cap must not drop the latest "newest" batch when marking read. */
|
||||
export const NEW_RELEASES_SEEN_MAX_IDS = 500;
|
||||
|
||||
/** Merge previous seen IDs with the current `getAlbumList(newest)` sample: newest batch is kept in full first, then older seen until `maxIds` (localStorage budget). */
|
||||
export function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of newestBatch) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
for (const id of prevSeen) {
|
||||
if (out.length >= maxIds) break;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
export function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
||||
const aside = document.querySelector('aside.sidebar');
|
||||
if (!aside) return false;
|
||||
const r = aside.getBoundingClientRect();
|
||||
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
|
||||
export type SidebarNavSection = 'library' | 'system';
|
||||
|
||||
export type SidebarNavDropTarget = {
|
||||
idx: number;
|
||||
before: boolean;
|
||||
section: SidebarNavSection;
|
||||
};
|
||||
|
||||
export function getLibraryItemsForReorder(
|
||||
items: SidebarItemConfig[],
|
||||
randomNavMode: 'hub' | 'separate',
|
||||
): SidebarItemConfig[] {
|
||||
return items.filter(cfg => {
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarItemConfig[] {
|
||||
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||
}
|
||||
|
||||
/** Same entries as in Settings toggles — safe to hide via drag-out. */
|
||||
export function isSidebarNavItemUserHideable(id: string): boolean {
|
||||
return Boolean(ALL_NAV_ITEMS[id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders one sidebar section (library or system) like the Settings customizer.
|
||||
* Returns a new `items` array, or null if nothing changes.
|
||||
*/
|
||||
export function applySidebarDropReorder(
|
||||
allItems: SidebarItemConfig[],
|
||||
section: SidebarNavSection,
|
||||
fromIdx: number,
|
||||
target: SidebarNavDropTarget | null,
|
||||
randomNavMode: 'hub' | 'separate',
|
||||
): SidebarItemConfig[] | null {
|
||||
if (!target || target.section !== section) return null;
|
||||
|
||||
const sectionItems =
|
||||
section === 'library'
|
||||
? [...getLibraryItemsForReorder(allItems, randomNavMode)]
|
||||
: [...getSystemItemsForReorder(allItems)];
|
||||
|
||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return null;
|
||||
|
||||
const [moved] = sectionItems.splice(fromIdx, 1);
|
||||
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
|
||||
const visibleIds = new Set(sectionItems.map(c => c.id));
|
||||
const next = [...allItems];
|
||||
const positions = next
|
||||
.map((cfg, i) => ({ cfg, i }))
|
||||
.filter(({ cfg }) => visibleIds.has(cfg.id))
|
||||
.map(({ i }) => i);
|
||||
positions.forEach((pos, i) => {
|
||||
next[pos] = sectionItems[i];
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Render a relative time string like "3 hours ago" / "in 2 weeks" with
|
||||
* the supplied locale. Navidrome returns `"0001-01-01T00:00:00Z"` for
|
||||
* never-accessed users — that round-trips to year 1, which `Date.parse`
|
||||
* turns into a wildly-negative epoch. We guard with a "is this after the
|
||||
* year 2001-ish" sanity check and fall back to the `neverLabel` so the
|
||||
* UI doesn't claim "2024 years ago".
|
||||
*/
|
||||
export function formatLastSeen(iso: string | null | undefined, locale: string, neverLabel: string): string {
|
||||
if (!iso) return neverLabel;
|
||||
const t = new Date(iso).getTime();
|
||||
if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel;
|
||||
const diffSec = (t - Date.now()) / 1000;
|
||||
const abs = Math.abs(diffSec);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
|
||||
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
||||
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
||||
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
|
||||
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
|
||||
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
|
||||
return rtf.format(Math.round(diffSec / 31536000), 'year');
|
||||
}
|
||||
Reference in New Issue
Block a user