mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal, cover upload), Backup/Restore, Albums year filter, Statistics Library Insights (playtime/genres/formats), Playlist cover upload, resizable tracklist columns for Playlists & Favorites, crossfade fine control, Settings Storage tab redesign, various fixes and UI polish. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
|
||||
const BACKUP_VERSION = 1;
|
||||
|
||||
const BACKUP_KEYS = [
|
||||
'psysonic-auth',
|
||||
'psysonic_theme',
|
||||
'psysonic_font',
|
||||
'psysonic_language',
|
||||
'psysonic_keybindings',
|
||||
'psysonic_sidebar',
|
||||
'psysonic-eq',
|
||||
'psysonic_global_shortcuts',
|
||||
'psysonic-player',
|
||||
];
|
||||
|
||||
export async function exportBackup(): Promise<string | null> {
|
||||
const stores: Record<string, unknown> = {};
|
||||
for (const key of BACKUP_KEYS) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (val !== null) {
|
||||
try {
|
||||
stores[key] = JSON.parse(val);
|
||||
} catch {
|
||||
stores[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: BACKUP_VERSION,
|
||||
app_version: appVersion,
|
||||
created_at: new Date().toISOString(),
|
||||
stores,
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const path = await save({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
defaultPath: `psysonic-backup-${today}.psybkp`,
|
||||
});
|
||||
|
||||
if (!path) return null;
|
||||
|
||||
const content = JSON.stringify(manifest, null, 2);
|
||||
await writeFile(path, new TextEncoder().encode(content));
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function importBackup(): Promise<void> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
multiple: false,
|
||||
title: 'Import Psysonic Backup',
|
||||
});
|
||||
|
||||
if (!path || typeof path !== 'string') return;
|
||||
|
||||
const raw = await readTextFile(path);
|
||||
const manifest = JSON.parse(raw);
|
||||
|
||||
if (typeof manifest.version !== 'number' || !manifest.stores || typeof manifest.stores !== 'object') {
|
||||
throw new Error('invalid_backup');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(manifest.stores)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ColDef {
|
||||
readonly key: string;
|
||||
readonly i18nKey?: string | null;
|
||||
readonly minWidth: number;
|
||||
readonly defaultWidth: number;
|
||||
readonly required: boolean;
|
||||
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
|
||||
readonly flex?: boolean;
|
||||
}
|
||||
|
||||
function loadPrefs(
|
||||
storageKey: string,
|
||||
columns: readonly ColDef[],
|
||||
): { widths: Record<string, number>; visible: Set<string> } {
|
||||
const defaultWidths: Record<string, number> = Object.fromEntries(
|
||||
columns.map(c => [c.key, c.defaultWidth]),
|
||||
);
|
||||
const defaultVisible = new Set<string>(columns.map(c => c.key));
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
|
||||
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
|
||||
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
|
||||
columns.filter(c => c.required).forEach(c => visible.add(c.key));
|
||||
return {
|
||||
widths: { ...defaultWidths, ...(parsed.widths ?? {}) },
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return { widths: defaultWidths, visible: defaultVisible };
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
|
||||
}
|
||||
|
||||
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>(
|
||||
() => loadPrefs(storageKey, columns).widths,
|
||||
);
|
||||
const [colVisible, setColVisible] = useState<Set<string>>(
|
||||
() => loadPrefs(storageKey, columns).visible,
|
||||
);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
// Refs to avoid stale closures in drag/save handlers
|
||||
const colWidthsRef = useRef(colWidths);
|
||||
const colVisibleRef = useRef(colVisible);
|
||||
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
||||
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
||||
|
||||
const visibleCols = useMemo(
|
||||
() => columns.filter(c => colVisible.has(c.key)),
|
||||
[columns, colVisible],
|
||||
);
|
||||
|
||||
const gridTemplate = useMemo(
|
||||
() =>
|
||||
visibleCols
|
||||
.map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
|
||||
.join(' '),
|
||||
[visibleCols, colWidths],
|
||||
);
|
||||
|
||||
// Minimum total width so the grid never squishes below its current column sizes.
|
||||
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
|
||||
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
|
||||
const gridMinWidth = useMemo(() => {
|
||||
const gapPx = 12; // --space-3
|
||||
const boxPaddingH = 24; // var(--space-3) * 2
|
||||
const colSum = visibleCols.reduce<number>(
|
||||
(s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
|
||||
0,
|
||||
);
|
||||
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
|
||||
return colSum + gaps + boxPaddingH;
|
||||
}, [visibleCols, colWidths]);
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
|
||||
[gridTemplate, gridMinWidth],
|
||||
);
|
||||
|
||||
// Excel-style column resize:
|
||||
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
|
||||
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const visCols = visibleCols; // stable for the drag duration
|
||||
const colDef = visCols[colIndex];
|
||||
const colKey = colDef.key;
|
||||
const colMin = columns.find(c => c.key === colKey)!.minWidth;
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[colKey];
|
||||
|
||||
let maxW = Infinity;
|
||||
const el = tracklistRef.current;
|
||||
if (el) {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl
|
||||
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
|
||||
: 12;
|
||||
const totalGaps = (visCols.length - 1) * gapPx;
|
||||
const otherFixed = visCols
|
||||
.filter((_, i) => i !== colIndex)
|
||||
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
|
||||
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
|
||||
}
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const delta = me.clientX - startX;
|
||||
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
|
||||
setColWidths(prev => ({ ...prev, [colKey]: newW }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[columns, visibleCols, colWidths, storageKey],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(key: string) => {
|
||||
const def = columns.find(c => c.key === key)!;
|
||||
if (def.required) return;
|
||||
setColVisible(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
savePrefs(storageKey, colWidthsRef.current, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[columns, storageKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [pickerOpen]);
|
||||
|
||||
return {
|
||||
colWidths,
|
||||
colVisible,
|
||||
visibleCols,
|
||||
gridStyle,
|
||||
startResize,
|
||||
toggleColumn,
|
||||
pickerOpen,
|
||||
setPickerOpen,
|
||||
pickerRef,
|
||||
tracklistRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user