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:
@@ -96,6 +96,14 @@ export interface InternetRadioStation {
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface RadioBrowserStation {
|
||||
stationuuid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -105,6 +113,7 @@ export interface SubsonicPlaylist {
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
comment?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
@@ -417,6 +426,33 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlaylistMeta(
|
||||
id: string,
|
||||
name: string,
|
||||
comment: string,
|
||||
isPublic: boolean,
|
||||
): Promise<void> {
|
||||
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
|
||||
}
|
||||
|
||||
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_playlist_cover', {
|
||||
serverUrl: baseUrl,
|
||||
playlistId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
await api('deletePlaylist.view', { id });
|
||||
}
|
||||
@@ -483,3 +519,79 @@ export async function updateInternetRadioStation(
|
||||
export async function deleteInternetRadioStation(id: string): Promise<void> {
|
||||
await api('deleteInternetRadioStation.view', { id });
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRadioCoverArt(id: string): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('delete_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
|
||||
return raw.map(s => ({
|
||||
stationuuid: s.stationuuid ?? '',
|
||||
name: s.name ?? '',
|
||||
url: s.url ?? '',
|
||||
favicon: s.favicon ?? '',
|
||||
tags: s.tags ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export const RADIO_PAGE_SIZE = 25;
|
||||
|
||||
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<[number[], string]>('fetch_url_bytes', { url });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,85 +47,25 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
// rest → persistent px values from useTracklistColumns hook
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
] as const;
|
||||
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: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
type ColKey = (typeof COLUMNS)[number]['key'];
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
|
||||
COLUMNS.map(c => [c.key, c.defaultWidth])
|
||||
) as Record<ColKey, number>;
|
||||
|
||||
const DEFAULT_VISIBLE = new Set<ColKey>([
|
||||
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
|
||||
]);
|
||||
|
||||
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_tracklist_columns');
|
||||
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
const parsed = JSON.parse(raw);
|
||||
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
|
||||
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
|
||||
return {
|
||||
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
}
|
||||
}
|
||||
|
||||
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
|
||||
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
|
||||
widths,
|
||||
visible: [...visible],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
|
||||
* Fixed columns (e.g. 'num') keep their width unchanged.
|
||||
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
|
||||
function fitColumnsToWidth(
|
||||
widths: Record<ColKey, number>,
|
||||
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
|
||||
targetW: number,
|
||||
gapPx: number
|
||||
): Record<ColKey, number> {
|
||||
if (vCols.length === 0 || targetW <= 0) return widths;
|
||||
const next = { ...widths };
|
||||
const fixedCols = vCols.filter(c => c.fixed);
|
||||
const flexCols = vCols.filter(c => !c.fixed);
|
||||
if (flexCols.length === 0) return next;
|
||||
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
|
||||
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
const available = targetW - totalGaps - fixedTotal;
|
||||
if (available <= 0) return next;
|
||||
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
if (currentFlexTotal === 0) return next;
|
||||
const ratio = available / currentFlexTotal;
|
||||
flexCols.forEach(c => {
|
||||
const key = c.key as ColKey;
|
||||
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
|
||||
});
|
||||
// Correct rounding drift in 'title' column
|
||||
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
|
||||
const diff = available - newFlexTotal;
|
||||
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
|
||||
const titleDef = COLUMNS.find(c => c.key === 'title')!;
|
||||
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
// Columns where cell content should be centred (both header and rows)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -164,142 +105,12 @@ export default function AlbumTrackList({
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state ──────────────────────────────────────────────────────────
|
||||
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
|
||||
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const prevContainerW = useRef(0);
|
||||
const colVisibleRef = useRef(colVisible);
|
||||
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
||||
|
||||
// Stores the user's last intentional column widths + the container W they match.
|
||||
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
|
||||
// This prevents drift when the window is shrunk and enlarged again.
|
||||
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
|
||||
// Tracks current colWidths without a useEffect dependency in callbacks
|
||||
const colWidthsRef = useRef(colWidths);
|
||||
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
||||
|
||||
// On mount: fit saved (or default) widths to current container; establish base.
|
||||
useLayoutEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
prevContainerW.current = containerW;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
setColWidths(prev => {
|
||||
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
|
||||
baseWidthsRef.current = { widths: fitted, containerW };
|
||||
return fitted;
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When the container resizes, scale all columns proportionally FROM the base.
|
||||
// Using the base (not prev) means shrink → grow always returns to exact original widths.
|
||||
useEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const newW = el.clientWidth - paddingH;
|
||||
if (Math.abs(newW - prevContainerW.current) < 2) return;
|
||||
prevContainerW.current = newW;
|
||||
const base = baseWidthsRef.current;
|
||||
if (!base) return;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
// Always scale from base.widths, never from current state → no drift
|
||||
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// All visible columns in order
|
||||
const visibleCols = useMemo(
|
||||
() => COLUMNS.filter(c => colVisible.has(c.key)),
|
||||
[colVisible]
|
||||
);
|
||||
|
||||
// Grid template: all fixed px — bidirectional resize works correctly
|
||||
const gridTemplate = useMemo(
|
||||
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
|
||||
[colWidths, visibleCols]
|
||||
);
|
||||
|
||||
const colStyle = { gridTemplateColumns: gridTemplate };
|
||||
|
||||
|
||||
// ── Bidirectional resize ─────────────────────────────────────────────────
|
||||
// Dragging the divider between col[colIndex] and col[colIndex+1]:
|
||||
// → right: colA grows, colB shrinks (clamped to minWidth)
|
||||
// → left: colA shrinks, colB grows (clamped to minWidth)
|
||||
// Excel-style resize: only the dragged column changes width.
|
||||
// Clamped so total never exceeds container width — no overflow, no scrollbar.
|
||||
const startResize = (e: React.MouseEvent, colIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const colA = visibleCols[colIndex];
|
||||
const defA = COLUMNS.find(c => c.key === colA.key)!;
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[colA.key as ColKey];
|
||||
const snapshotVisible = colVisible;
|
||||
|
||||
// Measure container once at drag start
|
||||
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) || 0) : 12;
|
||||
const sumOthers = visibleCols
|
||||
.filter((_, i) => i !== colIndex)
|
||||
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
|
||||
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
|
||||
}
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
|
||||
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
// Save final state and update base so future window resizes scale from here
|
||||
const finalWidths = colWidthsRef.current;
|
||||
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
|
||||
saveColPrefs(finalWidths, snapshotVisible);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const toggleColumn = (key: ColKey) => {
|
||||
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);
|
||||
saveColPrefs(colWidths, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// ── Column state (resize, visibility, picker) via shared hook ────────────
|
||||
const {
|
||||
colWidths, colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
@@ -333,17 +144,6 @@ export default function AlbumTrackList({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
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]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
@@ -356,13 +156,13 @@ export default function AlbumTrackList({
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
|
||||
const isCentered = CENTERED_COLS.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
|
||||
// num header: checkbox + # label, mirrors row-cell layout exactly
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
@@ -376,21 +176,31 @@ export default function AlbumTrackList({
|
||||
);
|
||||
}
|
||||
|
||||
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
|
||||
// direction=1: drag right → this column grows, title (1fr) shrinks
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={isCentered ? 'col-center' : undefined}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
{/* Resize handle on all non-fixed columns except the last */}
|
||||
{!isLastCol && !colDef.fixed && (
|
||||
<div
|
||||
className="col-resize-handle"
|
||||
onMouseDown={e => startResize(e, colIndex)}
|
||||
/>
|
||||
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -521,7 +331,7 @@ export default function AlbumTrackList({
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header" style={colStyle}>
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
@@ -538,14 +348,13 @@ export default function AlbumTrackList({
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{COLUMNS.filter(c => !c.required).map(c => {
|
||||
const key = c.key as ColKey;
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
|
||||
const isOn = colVisible.has(key);
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(key)}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">
|
||||
{isOn && <Check size={13} />}
|
||||
@@ -574,7 +383,7 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={colStyle}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
|
||||
@@ -20,7 +20,7 @@ function isNewer(a: string, b: string): boolean {
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null }
|
||||
| { phase: 'available'; version: string; update: Update | null; error?: string }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
@@ -82,9 +82,11 @@ export default function AppUpdater() {
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e) {
|
||||
console.error('Update failed', e);
|
||||
setState({ phase: 'available', version: savedVersion, update });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Update failed:', msg);
|
||||
// Surface the error so the user (and developer) can see what went wrong
|
||||
setState({ phase: 'available', version: savedVersion, update, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,6 +124,9 @@ export default function AppUpdater() {
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{state.error && (
|
||||
<div className="app-updater-error">{state.error}</div>
|
||||
)}
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
|
||||
@@ -148,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
|
||||
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
@@ -189,30 +189,24 @@ interface AutoEqVariant { form: string; rig: string | null; source: string; }
|
||||
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
|
||||
|
||||
|
||||
function parseGraphicEqString(graphicEqStr: string): number[] {
|
||||
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
|
||||
const points: [number, number][] = line
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
|
||||
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
|
||||
/** Parses AutoEQ FixedBandEQ.txt format.
|
||||
* Expected lines:
|
||||
* Preamp: -5.5 dB
|
||||
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
|
||||
* ...
|
||||
* Returns all 10 band gains as exact floats and the preamp value.
|
||||
*/
|
||||
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
|
||||
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
|
||||
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
|
||||
|
||||
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
|
||||
if (targetFreq <= points[0][0]) return points[0][1];
|
||||
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
|
||||
const lo = points[i], hi = points[i + 1];
|
||||
if (lo[0] === hi[0]) return lo[1];
|
||||
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
|
||||
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
|
||||
allFilters.slice(0, 10).forEach((m, i) => {
|
||||
gains[i] = parseFloat(m[1]);
|
||||
});
|
||||
|
||||
return { gains, preamp };
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
@@ -303,9 +297,8 @@ export default function Equalizer() {
|
||||
form: result.form,
|
||||
});
|
||||
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
|
||||
const newGains = parseGraphicEqString(text);
|
||||
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
|
||||
applyAutoEq(result.name, newGains, 0);
|
||||
const { gains: newGains, preamp } = parseFixedBandEqString(text);
|
||||
applyAutoEq(result.name, newGains, preamp);
|
||||
setAutoEqApplied(result.name);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
|
||||
@@ -61,11 +61,12 @@ export default function PlayerBar() {
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
|
||||
[currentRadio?.coverArt]
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
|
||||
[currentRadio?.coverArt, currentRadio?.id]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
|
||||
@@ -216,7 +216,10 @@ export default function QueuePanel() {
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
@@ -240,6 +243,9 @@ export default function QueuePanel() {
|
||||
let parsedData: any = null;
|
||||
try { parsedData = JSON.parse(detail.data); } catch { return; }
|
||||
|
||||
// Radio streams are not tracks — reject silently
|
||||
if (parsedData.type === 'radio') return;
|
||||
|
||||
const dropTarget = externalDropTargetRef.current;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
@@ -304,9 +310,9 @@ export default function QueuePanel() {
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
|
||||
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
|
||||
onMouseMove={e => {
|
||||
if (!isPsyDragging || !queueListRef.current) return;
|
||||
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
let found = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -469,22 +475,22 @@ export default function QueuePanel() {
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(Number(e.target.value));
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>1s</span><span>10s</span>
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
// radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
+319
-34
@@ -182,6 +182,7 @@ const enTranslation = {
|
||||
enqueueAll: 'Add all to queue',
|
||||
playAll: 'Play all',
|
||||
removeSong: 'Remove from favorites',
|
||||
stations: 'Radio Stations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -237,6 +238,10 @@ const enTranslation = {
|
||||
sortByArtist: 'A–Z (Artist)',
|
||||
sortNewest: 'Newest first',
|
||||
sortRandom: 'Random',
|
||||
yearFrom: 'From',
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artists',
|
||||
@@ -393,11 +398,19 @@ const enTranslation = {
|
||||
behavior: 'App Behavior',
|
||||
cacheTitle: 'Max. Storage Size',
|
||||
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
||||
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
|
||||
cacheUsedImages: 'Images:',
|
||||
cacheUsedOffline: 'Offline tracks:',
|
||||
cacheMaxLabel: 'Max. size',
|
||||
cacheClearBtn: 'Clear Cache',
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
offlineDirTitle: 'Offline Library (In-App)',
|
||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||
offlineDirDefault: 'Default (App Data)',
|
||||
offlineDirChange: 'Change Directory',
|
||||
offlineDirClear: 'Reset to Default',
|
||||
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
@@ -406,7 +419,8 @@ const enTranslation = {
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
@@ -433,17 +447,27 @@ const enTranslation = {
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Storage & Downloads',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
tabShortcuts: 'Shortcuts',
|
||||
tabInput: 'Input',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
backupImport: 'Import settings',
|
||||
backupImportDesc: 'Restores settings from a .psybkp file. The app will reload after import.',
|
||||
backupImportConfirm: 'This will overwrite all current settings. Continue?',
|
||||
backupSuccess: 'Backup saved',
|
||||
backupImportSuccess: 'Settings restored — reloading…',
|
||||
backupImportError: 'Invalid or corrupted backup file.',
|
||||
shortcutsReset: 'Reset to defaults',
|
||||
shortcutListening: 'Press a key…',
|
||||
shortcutUnbound: '—',
|
||||
@@ -468,7 +492,7 @@ const enTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Not available while Gapless is active',
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
@@ -601,6 +625,11 @@ const enTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Total Playtime',
|
||||
genreInsights: 'Genre Insights',
|
||||
formatDistribution: 'Format Distribution',
|
||||
formatSample: 'Sample of {{n}} tracks',
|
||||
computing: 'Computing…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Albums',
|
||||
recentlyAdded: 'Recently Added',
|
||||
@@ -703,6 +732,19 @@ const enTranslation = {
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Public',
|
||||
privateLabel: 'Private',
|
||||
editMeta: 'Edit playlist',
|
||||
editNamePlaceholder: 'Playlist name…',
|
||||
editCommentPlaceholder: 'Add a description…',
|
||||
editPublic: 'Public playlist',
|
||||
editSave: 'Save',
|
||||
editCancel: 'Cancel',
|
||||
changeCover: 'Change cover image',
|
||||
changeCoverLabel: 'Change photo',
|
||||
removeCover: 'Remove photo',
|
||||
coverUpdated: 'Cover updated',
|
||||
metaSaved: 'Playlist updated',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
@@ -719,6 +761,21 @@ const enTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internet Radio',
|
||||
openHomepage: 'Open homepage',
|
||||
changeCoverLabel: 'Change cover',
|
||||
removeCover: 'Remove cover',
|
||||
browseDirectory: 'Search Directory',
|
||||
directoryPlaceholder: 'Search stations…',
|
||||
noResults: 'No stations found.',
|
||||
stationAdded: 'Station added',
|
||||
filterAll: 'All',
|
||||
filterFavorites: 'Favorites',
|
||||
sortManual: 'Manual',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Newest',
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -903,6 +960,7 @@ const deTranslation = {
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
playAll: 'Alle abspielen',
|
||||
removeSong: 'Aus Favoriten entfernen',
|
||||
stations: 'Radiosender',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -958,6 +1016,10 @@ const deTranslation = {
|
||||
sortByArtist: 'A–Z (Künstler)',
|
||||
sortNewest: 'Neueste zuerst',
|
||||
sortRandom: 'Zufällig',
|
||||
yearFrom: 'Von',
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
},
|
||||
artists: {
|
||||
title: 'Künstler',
|
||||
@@ -1114,11 +1176,19 @@ const deTranslation = {
|
||||
behavior: 'App-Verhalten',
|
||||
cacheTitle: 'Max. Speichergröße',
|
||||
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
||||
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
|
||||
cacheUsedImages: 'Bilder:',
|
||||
cacheUsedOffline: 'Offline-Tracks:',
|
||||
cacheMaxLabel: 'Max. Größe',
|
||||
cacheClearBtn: 'Cache leeren',
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
offlineDirTitle: 'Offline-Bibliothek (In-App)',
|
||||
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
|
||||
offlineDirDefault: 'Standard (App-Daten)',
|
||||
offlineDirChange: 'Verzeichnis ändern',
|
||||
offlineDirClear: 'Auf Standard zurücksetzen',
|
||||
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
@@ -1127,7 +1197,8 @@ const deTranslation = {
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
@@ -1154,15 +1225,15 @@ const deTranslation = {
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Speicher & Downloads',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
tabShortcuts: 'Tastenkürzel',
|
||||
tabInput: 'Eingabe',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
shortcutListening: 'Taste drücken…',
|
||||
@@ -1180,7 +1251,17 @@ const deTranslation = {
|
||||
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabAbout: 'Info',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
backupImport: 'Einstellungen importieren',
|
||||
backupImportDesc: 'Stellt Einstellungen aus einer .psybkp-Datei wieder her. Die App wird nach dem Import neu geladen.',
|
||||
backupImportConfirm: 'Alle aktuellen Einstellungen werden überschrieben. Fortfahren?',
|
||||
backupSuccess: 'Backup gespeichert',
|
||||
backupImportSuccess: 'Einstellungen wiederhergestellt — wird neu geladen…',
|
||||
backupImportError: 'Ungültige oder beschädigte Backup-Datei.',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
|
||||
@@ -1189,7 +1270,7 @@ const deTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
@@ -1322,6 +1403,11 @@ const deTranslation = {
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Gesamtspielzeit',
|
||||
genreInsights: 'Genre-Einblicke',
|
||||
formatDistribution: 'Format-Verteilung',
|
||||
formatSample: 'Stichprobe von {{n}} Titeln',
|
||||
computing: 'Wird berechnet…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Alben',
|
||||
recentlyAdded: 'Neu hinzugefügt',
|
||||
@@ -1424,6 +1510,19 @@ const deTranslation = {
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
publicLabel: 'Öffentlich',
|
||||
privateLabel: 'Privat',
|
||||
editMeta: 'Playlist bearbeiten',
|
||||
editNamePlaceholder: 'Playlist-Name…',
|
||||
editCommentPlaceholder: 'Beschreibung hinzufügen…',
|
||||
editPublic: 'Öffentliche Playlist',
|
||||
editSave: 'Speichern',
|
||||
editCancel: 'Abbrechen',
|
||||
changeCover: 'Coverbild ändern',
|
||||
changeCoverLabel: 'Foto ändern',
|
||||
removeCover: 'Foto entfernen',
|
||||
coverUpdated: 'Cover aktualisiert',
|
||||
metaSaved: 'Playlist aktualisiert',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -1440,6 +1539,21 @@ const deTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage öffnen',
|
||||
changeCoverLabel: 'Cover ändern',
|
||||
removeCover: 'Cover entfernen',
|
||||
browseDirectory: 'Verzeichnis durchsuchen',
|
||||
directoryPlaceholder: 'Sender suchen…',
|
||||
noResults: 'Keine Sender gefunden.',
|
||||
stationAdded: 'Sender hinzugefügt',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favoriten',
|
||||
sortManual: 'Manuell',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Neueste',
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1624,6 +1738,7 @@ const frTranslation = {
|
||||
enqueueAll: 'Tout ajouter à la file',
|
||||
playAll: 'Tout lire',
|
||||
removeSong: 'Retirer des favoris',
|
||||
stations: 'Stations de radio',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Albums aléatoires',
|
||||
@@ -1679,6 +1794,10 @@ const frTranslation = {
|
||||
sortByArtist: 'A–Z (Artiste)',
|
||||
sortNewest: 'Plus récents',
|
||||
sortRandom: 'Aléatoire',
|
||||
yearFrom: 'De',
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistes',
|
||||
@@ -1835,11 +1954,19 @@ const frTranslation = {
|
||||
behavior: 'Comportement de l\'application',
|
||||
cacheTitle: 'Taille max. du stockage',
|
||||
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
||||
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
|
||||
cacheUsedImages: 'Images :',
|
||||
cacheUsedOffline: 'Pistes hors ligne :',
|
||||
cacheMaxLabel: 'Taille max.',
|
||||
cacheClearBtn: 'Vider le cache',
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
offlineDirTitle: 'Bibliothèque hors ligne (intégrée)',
|
||||
offlineDirDesc: 'Emplacement de stockage des titres rendus disponibles hors ligne dans Psysonic.',
|
||||
offlineDirDefault: 'Par défaut (données d\'application)',
|
||||
offlineDirChange: 'Changer le dossier',
|
||||
offlineDirClear: 'Réinitialiser',
|
||||
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
@@ -1848,7 +1975,8 @@ const frTranslation = {
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
pickFolderTitle: 'Sélectionner le dossier de téléchargement',
|
||||
@@ -1875,15 +2003,15 @@ const frTranslation = {
|
||||
randomMixBlacklistAdd: 'Ajouter',
|
||||
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
|
||||
randomMixHardcodedTitle: 'Mots-clés intégrés (actifs quand la case est cochée)',
|
||||
tabPlayback: 'Lecture',
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Stockage & Téléchargements',
|
||||
tabAppearance: 'Apparence',
|
||||
homeCustomizerTitle: 'Page d\'accueil',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
tabShortcuts: 'Raccourcis',
|
||||
tabInput: 'Entrée',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
shortcutListening: 'Appuyez sur une touche…',
|
||||
@@ -1901,7 +2029,17 @@ const frTranslation = {
|
||||
shortcutToggleQueue: 'Afficher/masquer la file',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabAbout: 'À propos',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
backupImport: 'Importer les paramètres',
|
||||
backupImportDesc: 'Restaure les paramètres depuis un fichier .psybkp. L\'application sera rechargée après l\'import.',
|
||||
backupImportConfirm: 'Tous les paramètres actuels seront écrasés. Continuer ?',
|
||||
backupSuccess: 'Sauvegarde enregistrée',
|
||||
backupImportSuccess: 'Paramètres restaurés — rechargement…',
|
||||
backupImportError: 'Fichier de sauvegarde invalide ou corrompu.',
|
||||
playbackTitle: 'Lecture',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normaliser le volume des pistes avec les métadonnées ReplayGain',
|
||||
@@ -1910,7 +2048,7 @@ const frTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
@@ -2043,6 +2181,11 @@ const frTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Morceaux',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Durée totale',
|
||||
genreInsights: 'Aperçu des genres',
|
||||
formatDistribution: 'Distribution des formats',
|
||||
formatSample: 'Échantillon de {{n}} pistes',
|
||||
computing: 'Calcul en cours…',
|
||||
genreSongs: '{{count}} morceaux',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Ajoutés récemment',
|
||||
@@ -2145,6 +2288,19 @@ const frTranslation = {
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Publique',
|
||||
privateLabel: 'Privée',
|
||||
editMeta: 'Modifier la playlist',
|
||||
editNamePlaceholder: 'Nom de la playlist…',
|
||||
editCommentPlaceholder: 'Ajouter une description…',
|
||||
editPublic: 'Playlist publique',
|
||||
editSave: 'Enregistrer',
|
||||
editCancel: 'Annuler',
|
||||
changeCover: 'Changer la pochette',
|
||||
changeCoverLabel: 'Changer la photo',
|
||||
removeCover: 'Supprimer la photo',
|
||||
coverUpdated: 'Pochette mise à jour',
|
||||
metaSaved: 'Playlist mise à jour',
|
||||
},
|
||||
radio: {
|
||||
title: 'Radio Internet',
|
||||
@@ -2161,6 +2317,21 @@ const frTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Radio Internet',
|
||||
openHomepage: 'Ouvrir la page d\'accueil',
|
||||
changeCoverLabel: 'Changer la pochette',
|
||||
removeCover: 'Supprimer la pochette',
|
||||
browseDirectory: 'Parcourir l\'annuaire',
|
||||
directoryPlaceholder: 'Rechercher des stations…',
|
||||
noResults: 'Aucune station trouvée.',
|
||||
stationAdded: 'Station ajoutée',
|
||||
filterAll: 'Toutes',
|
||||
filterFavorites: 'Favoris',
|
||||
sortManual: 'Manuel',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Plus récentes',
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2345,6 +2516,7 @@ const nlTranslation = {
|
||||
enqueueAll: 'Alles aan wachtrij toevoegen',
|
||||
playAll: 'Alles afspelen',
|
||||
removeSong: 'Verwijderen uit favorieten',
|
||||
stations: 'Radiostations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Willekeurige albums',
|
||||
@@ -2400,6 +2572,10 @@ const nlTranslation = {
|
||||
sortByArtist: 'A–Z (Artiest)',
|
||||
sortNewest: 'Nieuwste eerst',
|
||||
sortRandom: 'Willekeurig',
|
||||
yearFrom: 'Van',
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artiesten',
|
||||
@@ -2556,11 +2732,19 @@ const nlTranslation = {
|
||||
behavior: 'App-gedrag',
|
||||
cacheTitle: 'Max. opslaggrootte',
|
||||
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
||||
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
|
||||
cacheUsedImages: 'Afbeeldingen:',
|
||||
cacheUsedOffline: 'Offline nummers:',
|
||||
cacheMaxLabel: 'Max. grootte',
|
||||
cacheClearBtn: 'Cache wissen',
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
offlineDirTitle: 'Offline-bibliotheek (In-app)',
|
||||
offlineDirDesc: 'Opslaglocatie voor nummers die je offline beschikbaar maakt in Psysonic.',
|
||||
offlineDirDefault: 'Standaard (app-gegevens)',
|
||||
offlineDirChange: 'Map wijzigen',
|
||||
offlineDirClear: 'Terugzetten naar standaard',
|
||||
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
@@ -2569,7 +2753,8 @@ const nlTranslation = {
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
pickFolderTitle: 'Downloadmap selecteren',
|
||||
@@ -2596,15 +2781,15 @@ const nlTranslation = {
|
||||
randomMixBlacklistAdd: 'Toevoegen',
|
||||
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
|
||||
randomMixHardcodedTitle: 'Ingebouwde trefwoorden (actief wanneer selectievakje is aangevinkt)',
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Opslag & Downloads',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
tabShortcuts: 'Sneltoetsen',
|
||||
tabInput: 'Invoer',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
shortcutListening: 'Druk op een toets…',
|
||||
@@ -2622,7 +2807,17 @@ const nlTranslation = {
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabAbout: 'Over',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
backupImport: 'Instellingen importeren',
|
||||
backupImportDesc: 'Herstelt instellingen vanuit een .psybkp-bestand. De app wordt herladen na het importeren.',
|
||||
backupImportConfirm: 'Alle huidige instellingen worden overschreven. Doorgaan?',
|
||||
backupSuccess: 'Back-up opgeslagen',
|
||||
backupImportSuccess: 'Instellingen hersteld — herladen…',
|
||||
backupImportError: 'Ongeldig of beschadigd back-upbestand.',
|
||||
playbackTitle: 'Afspelen',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Nummervolume normaliseren met ReplayGain-metadata',
|
||||
@@ -2631,7 +2826,7 @@ const nlTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
@@ -2764,6 +2959,11 @@ const nlTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Nummers',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Totale speelduur',
|
||||
genreInsights: 'Genre-inzichten',
|
||||
formatDistribution: 'Formaatdistributie',
|
||||
formatSample: 'Steekproef van {{n}} nummers',
|
||||
computing: 'Berekenen…',
|
||||
genreSongs: '{{count}} nummers',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Recent toegevoegd',
|
||||
@@ -2866,6 +3066,19 @@ const nlTranslation = {
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Openbaar',
|
||||
privateLabel: 'Privé',
|
||||
editMeta: 'Playlist bewerken',
|
||||
editNamePlaceholder: 'Playlistnaam…',
|
||||
editCommentPlaceholder: 'Beschrijving toevoegen…',
|
||||
editPublic: 'Openbare playlist',
|
||||
editSave: 'Opslaan',
|
||||
editCancel: 'Annuleren',
|
||||
changeCover: 'Omslagafbeelding wijzigen',
|
||||
changeCoverLabel: 'Foto wijzigen',
|
||||
removeCover: 'Foto verwijderen',
|
||||
coverUpdated: 'Omslag bijgewerkt',
|
||||
metaSaved: 'Playlist bijgewerkt',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -2882,6 +3095,21 @@ const nlTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage openen',
|
||||
changeCoverLabel: 'Cover wijzigen',
|
||||
removeCover: 'Cover verwijderen',
|
||||
browseDirectory: 'Gids doorzoeken',
|
||||
directoryPlaceholder: 'Stations zoeken…',
|
||||
noResults: 'Geen stations gevonden.',
|
||||
stationAdded: 'Station toegevoegd',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favorieten',
|
||||
sortManual: 'Handmatig',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Nieuwste',
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3066,6 +3294,7 @@ const zhTranslation = {
|
||||
enqueueAll: '全部加入队列',
|
||||
playAll: '全部播放',
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
@@ -3121,6 +3350,10 @@ const zhTranslation = {
|
||||
sortByArtist: '按艺术家排序 (A-Z)',
|
||||
sortNewest: '最新优先',
|
||||
sortRandom: '随机排序',
|
||||
yearFrom: '从',
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
@@ -3273,11 +3506,19 @@ const zhTranslation = {
|
||||
behavior: '应用行为',
|
||||
cacheTitle: '最大存储大小',
|
||||
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
|
||||
cacheUsed: '已使用:{{images}} 张图片 · {{offline}} 首离线曲目',
|
||||
cacheUsedImages: '图片:',
|
||||
cacheUsedOffline: '离线曲目:',
|
||||
cacheMaxLabel: '最大容量',
|
||||
cacheClearBtn: '清除缓存',
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
offlineDirTitle: '离线音乐库(应用内)',
|
||||
offlineDirDesc: '用于在 Psysonic 中离线可用的曲目存储位置。',
|
||||
offlineDirDefault: '默认(应用数据)',
|
||||
offlineDirChange: '更改目录',
|
||||
offlineDirClear: '重置为默认',
|
||||
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
@@ -3286,7 +3527,8 @@ const zhTranslation = {
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
pickFolderTitle: '选择下载文件夹',
|
||||
@@ -3313,17 +3555,27 @@ const zhTranslation = {
|
||||
randomMixBlacklistAdd: '添加',
|
||||
randomMixBlacklistEmpty: '尚未添加自定义关键词。',
|
||||
randomMixHardcodedTitle: '内置关键词(复选框开启时生效)',
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAudio: '音频',
|
||||
tabStorage: '存储与下载',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
tabShortcuts: '快捷键',
|
||||
tabInput: '输入',
|
||||
tabServer: '服务器',
|
||||
tabAbout: '关于',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
backupImport: '导入设置',
|
||||
backupImportDesc: '从 .psybkp 文件恢复设置。导入后应用将重新加载。',
|
||||
backupImportConfirm: '所有当前设置将被覆盖。是否继续?',
|
||||
backupSuccess: '备份已保存',
|
||||
backupImportSuccess: '设置已恢复——正在重新加载…',
|
||||
backupImportError: '备份文件无效或已损坏。',
|
||||
shortcutsReset: '恢复默认',
|
||||
shortcutListening: '请按下按键…',
|
||||
shortcutUnbound: '—',
|
||||
@@ -3481,6 +3733,11 @@ const zhTranslation = {
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
computing: '计算中…',
|
||||
genreSongs: '{{count}} 首歌曲',
|
||||
genreAlbums: '{{count}} 张专辑',
|
||||
recentlyAdded: '最近添加',
|
||||
@@ -3583,6 +3840,19 @@ const zhTranslation = {
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
publicLabel: '公开',
|
||||
privateLabel: '私有',
|
||||
editMeta: '编辑播放列表',
|
||||
editNamePlaceholder: '播放列表名称…',
|
||||
editCommentPlaceholder: '添加描述…',
|
||||
editPublic: '公开播放列表',
|
||||
editSave: '保存',
|
||||
editCancel: '取消',
|
||||
changeCover: '更改封面图片',
|
||||
changeCoverLabel: '更改照片',
|
||||
removeCover: '删除照片',
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
@@ -3599,6 +3869,21 @@ const zhTranslation = {
|
||||
live: '直播',
|
||||
liveStream: '网络电台',
|
||||
openHomepage: '打开主页',
|
||||
changeCoverLabel: '更换封面',
|
||||
removeCover: '移除封面',
|
||||
browseDirectory: '搜索目录',
|
||||
directoryPlaceholder: '搜索电台…',
|
||||
noResults: '未找到电台。',
|
||||
stationAdded: '电台已添加',
|
||||
filterAll: '全部',
|
||||
filterFavorites: '收藏',
|
||||
sortManual: '手动',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: '最新',
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export default function AdvancedSearch() {
|
||||
)}
|
||||
</h2>
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}>
|
||||
<span />
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
@@ -287,7 +287,7 @@ export default function AdvancedSearch() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
|
||||
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
|
||||
+74
-11
@@ -3,10 +3,12 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
@@ -22,13 +24,26 @@ export default function Albums() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
const fromNum = parseInt(yearFrom, 10);
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
const load = useCallback(async (
|
||||
sortType: SortType,
|
||||
offset: number,
|
||||
append = false,
|
||||
yearFilter?: { from: number; to: number },
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList(sortType, PAGE_SIZE, offset);
|
||||
const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {};
|
||||
const type = yearFilter ? 'byYear' : sortType;
|
||||
const data = await getAlbumList(type, PAGE_SIZE, offset, extra);
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
@@ -54,16 +69,23 @@ export default function Albums() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filtered) loadFiltered(selectedGenres, sort);
|
||||
else { setPage(0); load(sort, 0); }
|
||||
}, [sort, filtered, selectedGenres, load, loadFiltered]);
|
||||
setPage(0);
|
||||
if (genreFiltered) {
|
||||
loadFiltered(selectedGenres, sort);
|
||||
} else if (yearActive) {
|
||||
load(sort, 0, false, { from: fromNum, to: toNum });
|
||||
} else {
|
||||
load(sort, 0);
|
||||
}
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || filtered) return;
|
||||
if (loading || !hasMore || genreFiltered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(sort, next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, sort, load, filtered]);
|
||||
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
|
||||
load(sort, next * PAGE_SIZE, true, yf);
|
||||
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -74,6 +96,8 @@ export default function Albums() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const clearYear = () => { setYearFrom(''); setYearTo(''); };
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
@@ -84,7 +108,7 @@ export default function Albums() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map(o => (
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
@@ -94,6 +118,45 @@ export default function Albums() {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Year range filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
style={{ padding: '4px 6px' }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +170,7 @@ export default function Albums() {
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
{!filtered && (
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
|
||||
@@ -388,7 +388,7 @@ export default function ArtistDetail() {
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
@@ -400,7 +400,7 @@ export default function ArtistDetail() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, topSongs.map(songToTrack));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -254,9 +254,9 @@ export default function Artists() {
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost" onClick={loadMore}>
|
||||
{t('artists.loadMore')}
|
||||
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={loadMore}>
|
||||
<ChevronDown size={16} /> {t('artists.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+270
-49
@@ -1,23 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import {
|
||||
getStarred, getInternetRadioStations,
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { ListPlus, Play, X } from 'lucide-react';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
const FAV_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: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
// ── Column resize/visibility (must be before early return) ───────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
@@ -28,18 +51,42 @@ export default function Favorites() {
|
||||
setStarredOverride(id, false);
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
|
||||
function unfavoriteStation(id: string) {
|
||||
setRadioStations(prev => prev.filter(s => s.id !== id));
|
||||
try {
|
||||
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
||||
next.delete(id);
|
||||
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
.then(res => {
|
||||
setAlbums(res.albums);
|
||||
setArtists(res.artists);
|
||||
setSongs(res.songs);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
const loadAll = async () => {
|
||||
const [starredResult] = await Promise.allSettled([
|
||||
getStarred(),
|
||||
]);
|
||||
if (starredResult.status === 'fulfilled') {
|
||||
setAlbums(starredResult.value.albums);
|
||||
setArtists(starredResult.value.artists);
|
||||
setSongs(starredResult.value.songs);
|
||||
}
|
||||
|
||||
// Radio favorites: read IDs from localStorage, fetch all stations, filter
|
||||
try {
|
||||
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
||||
if (favIds.size > 0) {
|
||||
const all = await getInternetRadioStations();
|
||||
setRadioStations(all.filter(s => favIds.has(s.id)));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
@@ -51,7 +98,7 @@ export default function Favorites() {
|
||||
}
|
||||
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -71,6 +118,20 @@ export default function Favorites() {
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{radioStations.length > 0 && (
|
||||
<RadioStationRow
|
||||
title={t('favorites.stations')}
|
||||
stations={radioStations}
|
||||
currentRadio={currentRadio}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={s => {
|
||||
if (currentRadio?.id === s.id && isPlaying) stop();
|
||||
else playRadio(s);
|
||||
}}
|
||||
onUnfavorite={unfavoriteStation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleSongs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
@@ -96,13 +157,57 @@ export default function Favorites() {
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div />
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
const isCentered = key === 'duration';
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{FAV_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
@@ -110,7 +215,7 @@ export default function Favorites() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
@@ -133,34 +238,36 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn-icon fav-remove-btn"
|
||||
data-tooltip={t('favorites.removeSong')}
|
||||
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
|
||||
aria-label={t('favorites.removeSong')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'duration': return (
|
||||
<div key="duration" className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
);
|
||||
case 'remove': return (
|
||||
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -172,3 +279,117 @@ export default function Favorites() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radio Station Row ─────────────────────────────────────────────────────────
|
||||
|
||||
interface RadioStationRowProps {
|
||||
title: string;
|
||||
stations: InternetRadioStation[];
|
||||
currentRadio: InternetRadioStation | null;
|
||||
isPlaying: boolean;
|
||||
onPlay: (s: InternetRadioStation) => void;
|
||||
onUnfavorite: (id: string) => void;
|
||||
}
|
||||
|
||||
function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
};
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{stations.map(s => (
|
||||
<RadioFavCard
|
||||
key={s.id}
|
||||
station={s}
|
||||
isActive={currentRadio?.id === s.id}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => onPlay(s)}
|
||||
onUnfavorite={() => onUnfavorite(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radio Favorite Card ───────────────────────────────────────────────────────
|
||||
|
||||
interface RadioFavCardProps {
|
||||
station: InternetRadioStation;
|
||||
isActive: boolean;
|
||||
isPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
onUnfavorite: () => void;
|
||||
}
|
||||
|
||||
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||
<Cast size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
{isActive && isPlaying && (
|
||||
<div className="radio-live-overlay">
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button className="album-card-details-btn" onClick={onPlay}>
|
||||
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<div className="album-card-title">{s.name}</div>
|
||||
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
className="radio-favorite-btn active"
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
|
||||
onClick={onUnfavorite}
|
||||
data-tooltip={t('radio.unfavorite')}
|
||||
>
|
||||
<Heart size={12} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+809
-217
File diff suppressed because it is too large
Load Diff
+434
-136
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -15,6 +17,7 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -53,6 +56,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
const PL_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: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
||||
|
||||
export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
@@ -78,6 +95,8 @@ export default function PlaylistDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [editingMeta, setEditingMeta] = useState(false);
|
||||
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -149,10 +168,20 @@ export default function PlaylistDetail() {
|
||||
}),
|
||||
[coverQuad]);
|
||||
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
|
||||
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
|
||||
|
||||
const customCoverFetchUrl = useMemo(
|
||||
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
);
|
||||
const customCoverCacheKey = useMemo(
|
||||
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
);
|
||||
|
||||
// Song search
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -164,8 +193,14 @@ export default function PlaylistDetail() {
|
||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
|
||||
// ── Column resize/visibility ──────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
|
||||
|
||||
// DnD
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,6 +215,7 @@ export default function PlaylistDetail() {
|
||||
.then(({ playlist, songs }) => {
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
@@ -230,6 +266,34 @@ export default function PlaylistDetail() {
|
||||
setSaving(false);
|
||||
}, [id, touchPlaylist]);
|
||||
|
||||
// ── Meta edit ─────────────────────────────────────────────────
|
||||
const handleSaveMeta = async (opts: {
|
||||
name: string; comment: string; isPublic: boolean;
|
||||
coverFile: File | null; coverRemoved: boolean;
|
||||
}) => {
|
||||
if (!id || !playlist) return;
|
||||
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
|
||||
setPlaylist(p => p
|
||||
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
|
||||
: p
|
||||
);
|
||||
if (opts.coverFile) {
|
||||
try {
|
||||
await uploadPlaylistCoverArt(id, opts.coverFile);
|
||||
const { playlist: refreshed } = await getPlaylist(id);
|
||||
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
|
||||
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
|
||||
showToast(t('playlists.coverUpdated'));
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
|
||||
}
|
||||
} else if (opts.coverRemoved) {
|
||||
setCustomCoverId(null);
|
||||
}
|
||||
showToast(t('playlists.metaSaved'));
|
||||
setEditingMeta(false);
|
||||
};
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
@@ -390,21 +454,61 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
|
||||
<div className="album-detail-hero">
|
||||
{/* 2×2 cover grid */}
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
{/* Cover — click to open edit modal */}
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-hero-cover-overlay">
|
||||
<Camera size={28} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
||||
<h1 className="album-detail-title">{playlist.name}</h1>
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{playlist.comment && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
||||
)}
|
||||
</>
|
||||
<div className="album-detail-info">
|
||||
<span>{t('playlists.songs', { n: songs.length })}</span>
|
||||
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
|
||||
{playlist.public !== undefined && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
· {playlist.public
|
||||
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
|
||||
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
|
||||
</span>
|
||||
)}
|
||||
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
@@ -545,19 +649,75 @@ export default function PlaylistDetail() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist">
|
||||
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
|
||||
{selectedIds.size > 0
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
|
||||
: '#'}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = PL_CENTERED.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return (
|
||||
<div key="num" className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && key !== 'delete' && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{PL_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackRating')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
@@ -578,6 +738,7 @@ export default function PlaylistDetail() {
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onClick={e => {
|
||||
@@ -594,76 +755,49 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||
{(() => {
|
||||
{visibleCols.map(colDef => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title */}
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Artist */}
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
|
||||
{/* Favorite */}
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
|
||||
|
||||
{/* Duration */}
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); removeSong(idx); }}
|
||||
data-tooltip={t('playlists.removeSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(tracks[idx], tracks); }}>
|
||||
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} />
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return (
|
||||
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'favorite': return (
|
||||
<div key="favorite" className="track-star-cell">
|
||||
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
<div key="delete" className="playlist-row-delete-cell">
|
||||
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(idx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
@@ -673,9 +807,12 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* Total row */}
|
||||
{songs.length > 0 && (
|
||||
<div className="tracklist-total tracklist-va tracklist-playlist">
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
|
||||
<div className="tracklist-total" style={gridStyle}>
|
||||
{visibleCols.map(c => {
|
||||
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
|
||||
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
|
||||
return <span key={c.key} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -701,21 +838,24 @@ export default function PlaylistDetail() {
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
|
||||
<>
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div />
|
||||
<div />
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isCentered = PL_CENTERED.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return <div key="num" className="col-center">#</div>;
|
||||
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
if (key === 'favorite' || key === 'rating') return <div key={key} />;
|
||||
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={gridStyle}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onClick={e => {
|
||||
@@ -728,42 +868,200 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
{/* no star/rating for suggestions */}
|
||||
<div />
|
||||
<div />
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
|
||||
onClick={e => { e.stopPropagation(); addSong(song); }}
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'favorite': return <div key="favorite" />;
|
||||
case 'rating': return <div key="rating" />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
<div key="delete" className="playlist-row-delete-cell">
|
||||
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingMeta && playlist && (
|
||||
<PlaylistEditModal
|
||||
playlist={playlist}
|
||||
customCoverId={customCoverId}
|
||||
customCoverFetchUrl={customCoverFetchUrl ?? null}
|
||||
customCoverCacheKey={customCoverCacheKey ?? null}
|
||||
coverQuadUrls={coverQuadUrls}
|
||||
onClose={() => setEditingMeta(false)}
|
||||
onSave={handleSaveMeta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
|
||||
|
||||
interface EditModalProps {
|
||||
playlist: SubsonicPlaylist;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
onClose: () => void;
|
||||
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
function PlaylistEditModal({
|
||||
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
|
||||
coverQuadUrls, onClose, onSave,
|
||||
}: EditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(playlist.name);
|
||||
const [comment, setComment] = useState(playlist.comment ?? '');
|
||||
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const [coverRemoved, setCoverRemoved] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setCoverFile(file);
|
||||
setCoverRemoved(false);
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => setCoverPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleRemoveCover = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCoverFile(null);
|
||||
setCoverPreview(null);
|
||||
setCoverRemoved(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
|
||||
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
|
||||
|
||||
<div className="playlist-edit-body">
|
||||
{/* Left: cover */}
|
||||
<div
|
||||
className="playlist-edit-cover-wrap"
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
>
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-edit-cover-overlay">
|
||||
<div className="playlist-edit-cover-menu">
|
||||
<button
|
||||
className="playlist-edit-cover-menu-item"
|
||||
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('playlists.changeCoverLabel')}
|
||||
</button>
|
||||
{hasExistingCover && (
|
||||
<button
|
||||
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
|
||||
onClick={handleRemoveCover}
|
||||
>
|
||||
{t('playlists.removeCover')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
</div>
|
||||
|
||||
{/* Right: fields */}
|
||||
<div className="playlist-edit-fields">
|
||||
<input
|
||||
className="input playlist-edit-name-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('playlists.editNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
className="input playlist-edit-desc-input"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder={t('playlists.editCommentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="playlist-edit-footer">
|
||||
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<label className="toggle-switch" style={{ marginBottom: 0 }}>
|
||||
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
|
||||
</label>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
|
||||
{t('playlists.editSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function RandomMix() {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
@@ -344,7 +344,7 @@ export default function RandomMix() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
@@ -414,7 +414,7 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
@@ -443,7 +443,7 @@ export default function RandomMix() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function SearchResults() {
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}>
|
||||
<div />
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
@@ -81,7 +81,7 @@ export default function SearchResults() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
|
||||
onDoubleClick={() => playSong(song, results.songs)}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
|
||||
+297
-150
@@ -5,8 +5,10 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download
|
||||
} from 'lucide-react';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
@@ -82,7 +84,7 @@ const SPECIAL_THANKS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -162,7 +164,7 @@ export default function Settings() {
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'general');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -182,9 +184,9 @@ export default function Settings() {
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'library') return;
|
||||
if (activeTab !== 'storage') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
}, [activeTab]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
@@ -193,7 +195,7 @@ export default function Settings() {
|
||||
await clearAllOffline(serverId);
|
||||
const [imgBytes, offBytes] = await Promise.all([
|
||||
getImageCacheSize(),
|
||||
invoke<number>('get_offline_cache_size').catch(() => 0),
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
|
||||
]);
|
||||
setImageCacheBytes(imgBytes);
|
||||
setOfflineCacheBytes(offBytes);
|
||||
@@ -299,6 +301,13 @@ export default function Settings() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const pickOfflineDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
auth.setOfflineDownloadDir(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const pickDownloadFolder = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
@@ -307,12 +316,13 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
|
||||
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
|
||||
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -333,8 +343,8 @@ export default function Settings() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── Playback ─────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'playback' && (
|
||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'audio' && (
|
||||
<>
|
||||
{/* Equalizer */}
|
||||
<section className="settings-section">
|
||||
@@ -407,16 +417,16 @@ export default function Settings() {
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -447,63 +457,59 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Library ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'library' && (
|
||||
{/* ── General ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'general' && (
|
||||
<>
|
||||
{/* Cache */}
|
||||
{/* App behaviour */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.cacheDesc')}
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
|
||||
— {t('settings.cacheUsed', {
|
||||
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
|
||||
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -595,6 +601,143 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Storage & Downloads ───────────────────────────────────────────────── */}
|
||||
{activeTab === 'storage' && (
|
||||
<>
|
||||
{/* Offline Library (In-App) — includes cache settings */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Download size={18} />
|
||||
<h2>{t('settings.offlineDirTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.offlineDirDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.offlineDownloadDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setOfflineDownloadDir('')}
|
||||
data-tooltip={t('settings.offlineDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
||||
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.offlineDownloadDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.offlineDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
|
||||
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
|
||||
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={100}
|
||||
max={50000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 100) auth.setMaxCacheMb(v);
|
||||
}}
|
||||
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
|
||||
id="cache-size-input"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||||
</div>
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ZIP Export & Archiving */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<FolderOpen size={18} />
|
||||
<h2>{t('settings.downloadsTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.downloadsFolderDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
@@ -668,13 +811,13 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'input' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.tabShortcuts')}</h2>
|
||||
<h2>{t('settings.tabInput')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
@@ -993,89 +1136,13 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
|
||||
{auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── About ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'about' && (
|
||||
{/* ── System ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'system' && (
|
||||
<>
|
||||
<BackupSection />
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
@@ -1417,6 +1484,86 @@ function SidebarCustomizer() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackupSection() {
|
||||
const { t } = useTranslation();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const path = await exportBackup();
|
||||
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!window.confirm(t('settings.backupImportConfirm'))) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
await importBackup();
|
||||
// importBackup reloads the page — this toast will briefly show before reload
|
||||
showToast(t('settings.backupImportSuccess'), 3000, 'info');
|
||||
} catch (e) {
|
||||
console.error('Import failed', e);
|
||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2>{t('settings.backupTitle')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Upload size={14} />
|
||||
{exporting ? '…' : t('settings.backupExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Download size={14} />
|
||||
{importing ? '…' : t('settings.backupImport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogSection() {
|
||||
const { t } = useTranslation();
|
||||
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
|
||||
|
||||
+151
-7
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -14,6 +14,13 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
|
||||
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
{ key: '7day', label: 'lfmPeriod7day' },
|
||||
{ key: '1month', label: 'lfmPeriod1month' },
|
||||
@@ -32,8 +39,14 @@ export default function Statistics() {
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
|
||||
const [playtimeCapped, setPlaytimeCapped] = useState(false);
|
||||
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
|
||||
const [formatSampleSize, setFormatSampleSize] = useState(0);
|
||||
|
||||
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
|
||||
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
|
||||
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
|
||||
@@ -54,12 +67,62 @@ export default function Statistics() {
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setArtistCount(a.length);
|
||||
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
|
||||
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
||||
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
||||
setGenres(sorted);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
const pageSize = 500;
|
||||
const maxPages = 10;
|
||||
let capped = false;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
try {
|
||||
const albums = await getAlbumList('newest', pageSize, offset);
|
||||
if (cancelled) return;
|
||||
for (const a of albums) total += (a.duration ?? 0);
|
||||
if (albums.length < pageSize) break;
|
||||
if (page === maxPages - 1) capped = true;
|
||||
offset += pageSize;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(total);
|
||||
setPlaytimeCapped(capped);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Background fetch: format distribution (sample of 500 random songs)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRandomSongs(500).then(songs => {
|
||||
if (cancelled) return;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const sorted = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
setFormatData(sorted);
|
||||
setFormatSampleSize(songs.length);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmRecentLoading(true);
|
||||
@@ -97,12 +160,20 @@ export default function Statistics() {
|
||||
}
|
||||
};
|
||||
|
||||
const playtimeDisplay = totalPlaytime === null
|
||||
? t('statistics.computing')
|
||||
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs },
|
||||
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
|
||||
];
|
||||
|
||||
const topGenres = genres.slice(0, 10);
|
||||
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title">{t('statistics.title')}</h1>
|
||||
@@ -115,12 +186,85 @@ export default function Statistics() {
|
||||
<div className="stats-overview">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="stats-card">
|
||||
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
|
||||
<span className="stats-card-value">{s.value}</span>
|
||||
<span className="stats-card-label">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Genre Insights + Format Distribution */}
|
||||
{(topGenres.length > 0 || formatData) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||
|
||||
{topGenres.length > 0 && (
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||
{t('statistics.genreInsights')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{topGenres.map(g => (
|
||||
<div key={g.value}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
|
||||
{g.value}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
{g.songCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${(g.songCount / maxGenreSongs) * 100}%`,
|
||||
background: 'var(--accent)',
|
||||
opacity: 0.7,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formatData && (
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
|
||||
{t('statistics.formatDistribution')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{formatData.map(f => {
|
||||
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
|
||||
return (
|
||||
<div key={f.format}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
background: 'var(--accent)',
|
||||
opacity: 0.6,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recent.length > 0 && (
|
||||
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface AuthState {
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
downloadFolder: string;
|
||||
offlineDownloadDir: string;
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
@@ -62,6 +63,7 @@ interface AuthState {
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setOfflineDownloadDir: (v: string) => void;
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
@@ -99,6 +101,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
scrobblingEnabled: true,
|
||||
maxCacheMb: 500,
|
||||
downloadFolder: '',
|
||||
offlineDownloadDir: '',
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
@@ -162,6 +165,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
|
||||
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||
setReplayGainEnabled: (v) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
id: string;
|
||||
@@ -156,6 +158,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
|
||||
const suffix = song.suffix || 'mp3';
|
||||
const url = buildStreamUrl(song.id);
|
||||
const customDir = useAuthStore.getState().offlineDownloadDir || null;
|
||||
|
||||
try {
|
||||
const localPath = await invoke<string>('download_track_offline', {
|
||||
@@ -163,6 +166,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
serverId,
|
||||
url,
|
||||
suffix,
|
||||
customDir,
|
||||
});
|
||||
|
||||
set(state => ({
|
||||
@@ -195,7 +199,11 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
|
||||
if (msg === 'VOLUME_NOT_FOUND') {
|
||||
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
|
||||
}
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
@@ -263,9 +271,8 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
const meta = get().tracks[`${serverId}:${trackId}`];
|
||||
if (!meta) return;
|
||||
await invoke('delete_offline_track', {
|
||||
trackId,
|
||||
serverId,
|
||||
suffix: meta.suffix,
|
||||
localPath: meta.localPath,
|
||||
baseDir: useAuthStore.getState().offlineDownloadDir || null,
|
||||
}).catch(() => {});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -158,6 +158,24 @@ let seekTarget: number | null = null;
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||
// Internet radio streams are played via a native <audio> element instead of
|
||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||
// codec support (MP3, AAC, HE-AAC, OGG) and stable ICY stream handling for
|
||||
// free, without touching the regular playback pipeline at all.
|
||||
const radioAudio = new Audio();
|
||||
radioAudio.preload = 'none';
|
||||
let radioStopping = false;
|
||||
radioAudio.addEventListener('ended', () => {
|
||||
// Stream disconnected unexpectedly — clear radio state.
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
});
|
||||
radioAudio.addEventListener('error', () => {
|
||||
if (radioStopping) { radioStopping = false; return; }
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
});
|
||||
|
||||
// Timestamp of the last gapless auto-advance (from audio:track_switched).
|
||||
// Used to suppress ghost-commands from stale IPC arriving after the switch.
|
||||
let lastGaplessSwitchTime = 0;
|
||||
@@ -579,7 +597,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── stop ────────────────────────────────────────────────────────────────
|
||||
stop: () => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
if (get().currentRadio) {
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
}
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
||||
@@ -592,8 +616,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
invoke('audio_play_radio', { url: station.streamUrl, volume }).catch((err: unknown) => {
|
||||
console.error('[psysonic] audio_play_radio failed:', err);
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
||||
radioAudio.src = station.streamUrl;
|
||||
radioAudio.volume = volume;
|
||||
radioAudio.play().catch((err: unknown) => {
|
||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||
set({ isPlaying: false, currentRadio: null });
|
||||
});
|
||||
set({
|
||||
@@ -622,13 +651,23 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
gaplessPreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||
if (get().currentRadio) {
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: 0,
|
||||
@@ -680,12 +719,21 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
if (get().currentRadio) {
|
||||
radioAudio.pause();
|
||||
} else {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
}
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
set({ isPlaying: true });
|
||||
return;
|
||||
}
|
||||
const { currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
|
||||
@@ -912,6 +960,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||
radioAudio.volume = clamped;
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
|
||||
+282
-13
@@ -1217,10 +1217,13 @@
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
contain: layout;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1277,13 +1280,14 @@
|
||||
}
|
||||
|
||||
/* ── Column resize handle ── */
|
||||
/* Sits at the right edge of each header cell, fully inside the cell bounds */
|
||||
/* Positioned flush with the right edge of its header cell.
|
||||
The ::after pseudo-element renders the visible 1 px divider line. */
|
||||
.col-resize-handle {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -1291,10 +1295,11 @@
|
||||
.col-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
width: 1px;
|
||||
border-radius: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
transition: background 0.15s;
|
||||
@@ -1393,12 +1398,6 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Playlist tracklist variant — adds delete column ── */
|
||||
.tracklist-header.tracklist-playlist,
|
||||
.track-row.tracklist-playlist {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 80px 80px 36px;
|
||||
}
|
||||
|
||||
/* Delete button in playlist row */
|
||||
.playlist-row-delete-cell {
|
||||
display: flex;
|
||||
@@ -1651,10 +1650,12 @@
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
@@ -1793,6 +1794,130 @@
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* ─ Playlist edit modal ─ */
|
||||
.playlist-edit-modal {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.playlist-edit-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Clickable cover wrap in the hero (opens modal) */
|
||||
.playlist-hero-cover {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-hero-cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.playlist-hero-cover:hover .playlist-hero-cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Cover area inside the modal */
|
||||
.playlist-edit-cover-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-wrap:hover .playlist-edit-cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item--danger {
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
}
|
||||
|
||||
/* Right side fields */
|
||||
.playlist-edit-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.playlist-edit-name-input {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.playlist-edit-desc-input {
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.playlist-edit-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.artist-bio {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
@@ -4964,6 +5089,150 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─ Radio Toolbar ─ */
|
||||
.radio-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.radio-toolbar-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.radio-toolbar-chips::-webkit-scrollbar { display: none; }
|
||||
.radio-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.radio-filter-chip:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.radio-filter-chip.active { background: var(--accent); color: var(--ctp-crust); border-color: var(--accent); }
|
||||
|
||||
|
||||
.radio-card-drop-before {
|
||||
box-shadow: -3px 0 0 0 var(--accent);
|
||||
}
|
||||
.radio-card-drop-after {
|
||||
box-shadow: 3px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* ─ Radio card edit chip ─ */
|
||||
.radio-card-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--text-secondary);
|
||||
color: var(--ctp-surface1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.radio-card-chip:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─ Alphabet Filter Bar ─ */
|
||||
.alphabet-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.alphabet-filter-btn {
|
||||
min-width: 26px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.alphabet-filter-btn:hover { background: var(--bg-hover); }
|
||||
.alphabet-filter-btn.active { background: var(--accent); color: var(--ctp-crust); }
|
||||
.alphabet-filter-btn.empty { opacity: 0.28; pointer-events: none; }
|
||||
|
||||
/* ─ Radio favorite star ─ */
|
||||
.radio-favorite-btn { color: rgba(255, 255, 255, 0.35); }
|
||||
.radio-favorite-btn:hover { color: rgba(255, 255, 255, 0.8); }
|
||||
.radio-favorite-btn.active { color: var(--accent); }
|
||||
|
||||
/* ─ Radio Browser Directory ─ */
|
||||
.radio-browser-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 6px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.12s;
|
||||
cursor: default;
|
||||
}
|
||||
.radio-browser-result.clickable { cursor: pointer; }
|
||||
.radio-browser-result.clickable:hover { background: var(--bg-hover); }
|
||||
.radio-browser-result.added { opacity: 0.6; }
|
||||
.radio-browser-action {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radio-browser-favicon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio-browser-favicon--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.radio-browser-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.radio-browser-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.radio-browser-tags {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -311,6 +311,15 @@
|
||||
font-style: italic;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.app-updater-error {
|
||||
font-size: 10px;
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.app-updater-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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