mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+25
-1
@@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults';
|
||||
import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -141,7 +142,7 @@ function AppShell() {
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDraggingQueue) {
|
||||
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
|
||||
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
|
||||
setQueueWidth(newWidth);
|
||||
}
|
||||
}, [isDraggingQueue]);
|
||||
@@ -185,14 +186,36 @@ function AppShell() {
|
||||
// from the OS file manager) is dropped on the document body.
|
||||
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
|
||||
|
||||
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
|
||||
const blockSelectAll = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
const target = e.target as HTMLElement;
|
||||
// Allow Ctrl+A inside actual text inputs and textareas
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
|
||||
const blockSelectStart = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
if ((target as HTMLElement).closest('[data-selectable]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('dragover', allow);
|
||||
document.addEventListener('dragenter', allow);
|
||||
document.addEventListener('drop', blockDrop);
|
||||
document.addEventListener('keydown', blockSelectAll, true);
|
||||
document.addEventListener('selectstart', blockSelectStart);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', allow);
|
||||
document.removeEventListener('dragenter', allow);
|
||||
document.removeEventListener('drop', blockDrop);
|
||||
document.removeEventListener('keydown', blockSelectAll, true);
|
||||
document.removeEventListener('selectstart', blockSelectStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -258,6 +281,7 @@ function AppShell() {
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -88,6 +88,14 @@ export interface SubsonicSong {
|
||||
};
|
||||
}
|
||||
|
||||
export interface InternetRadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
homepageUrl?: string;
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -442,3 +450,36 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Internet Radio ───────────────────────────────────────────
|
||||
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
|
||||
try {
|
||||
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
|
||||
'getInternetRadioStations.view'
|
||||
);
|
||||
return data.internetRadioStations?.internetRadioStation ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createInternetRadioStation(
|
||||
name: string, streamUrl: string, homepageUrl?: string
|
||||
): Promise<void> {
|
||||
const params: Record<string, unknown> = { name, streamUrl };
|
||||
if (homepageUrl) params.homepageUrl = homepageUrl;
|
||||
await api('createInternetRadioStation.view', params);
|
||||
}
|
||||
|
||||
export async function updateInternetRadioStation(
|
||||
id: string, name: string, streamUrl: string, homepageUrl?: string
|
||||
): Promise<void> {
|
||||
const params: Record<string, unknown> = { id, name, streamUrl };
|
||||
if (homepageUrl) params.homepageUrl = homepageUrl;
|
||||
await api('updateInternetRadioStation.view', params);
|
||||
}
|
||||
|
||||
export async function deleteInternetRadioStation(id: string): Promise<void> {
|
||||
await api('deleteInternetRadioStation.view', { id });
|
||||
}
|
||||
|
||||
@@ -81,7 +81,11 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
<p
|
||||
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||
>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,10 @@ import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Heart, ListPlus, X } from 'lucide-react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
if (song.bitRate) parts.push(`${song.bitRate}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
@@ -42,6 +45,89 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
|
||||
type ColKey = (typeof COLUMNS)[number]['key'];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
hasVariousArtists: boolean;
|
||||
@@ -68,15 +154,153 @@ export default function AlbumTrackList({
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
// ── Bulk select ───────────────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -99,7 +323,6 @@ export default function AlbumTrackList({
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Close playlist picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -110,6 +333,15 @@ 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[]>();
|
||||
@@ -123,8 +355,137 @@ export default function AlbumTrackList({
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Row cell renderer ─────────────────────────────────────────────────────
|
||||
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
|
||||
switch (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(); onPlaySong(song); }}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, 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">{song.track ?? '—'}</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 => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating':
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
case 'duration':
|
||||
return (
|
||||
<div key="duration" className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
);
|
||||
case 'format':
|
||||
return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'genre':
|
||||
return (
|
||||
<div key="genre" className="track-genre">
|
||||
{song.genre ?? '—'}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
@@ -158,20 +519,47 @@ export default function AlbumTrackList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`tracklist-header${' tracklist-va'}`}>
|
||||
<div className="col-center">
|
||||
{inSelectMode
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
|
||||
: '#'}
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header" style={colStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
{/* Column visibility picker */}
|
||||
<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>
|
||||
{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);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(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>
|
||||
|
||||
{/* ── Tracks ── */}
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
@@ -186,6 +574,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}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
@@ -216,59 +605,13 @@ export default function AlbumTrackList({
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={`tracklist-total${' tracklist-va'}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import CustomSelect from './CustomSelect';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -147,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.5) * 0.5; // snap to 0.5 dB
|
||||
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
@@ -182,20 +183,63 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const preGain = useEqStore(s => s.preGain);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// AutoEQ state
|
||||
const [autoEqOpen, setAutoEqOpen] = useState(false);
|
||||
const [autoEqQuery, setAutoEqQuery] = useState('');
|
||||
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
|
||||
const [autoEqLoading, setAutoEqLoading] = useState(false);
|
||||
const [autoEqError, setAutoEqError] = useState<string | null>(null);
|
||||
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
|
||||
const [entriesLoading, setEntriesLoading] = useState(false);
|
||||
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
@@ -216,6 +260,63 @@ export default function Equalizer() {
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// AutoEQ: load entries index lazily when section opens, then filter client-side
|
||||
async function ensureEntries() {
|
||||
if (entriesCacheRef.current) return;
|
||||
setEntriesLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const json = await invoke<string>('autoeq_entries');
|
||||
entriesCacheRef.current = JSON.parse(json);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
|
||||
} finally {
|
||||
setEntriesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = autoEqQuery.trim().toLowerCase();
|
||||
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
|
||||
const flat: AutoEqResult[] = [];
|
||||
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
|
||||
if (!name.toLowerCase().includes(q)) continue;
|
||||
for (const v of variants) {
|
||||
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
setAutoEqResults(flat);
|
||||
// entriesLoading in deps: re-runs after entries finish loading so a query typed
|
||||
// during loading produces results immediately without needing a re-type.
|
||||
}, [autoEqQuery, entriesLoading]);
|
||||
|
||||
async function applyAutoEqResult(result: AutoEqResult) {
|
||||
setAutoEqLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const text = await invoke<string>('autoeq_fetch_profile', {
|
||||
name: result.name,
|
||||
source: result.source,
|
||||
rig: result.rig ?? null,
|
||||
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);
|
||||
setAutoEqApplied(result.name);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setTimeout(() => setAutoEqApplied(null), 3000);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
|
||||
} finally {
|
||||
setAutoEqLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
@@ -279,6 +380,74 @@ export default function Equalizer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AutoEQ section */}
|
||||
<div className="eq-autoeq-section">
|
||||
<button
|
||||
className="eq-autoeq-toggle"
|
||||
onClick={() => {
|
||||
const opening = !autoEqOpen;
|
||||
setAutoEqOpen(opening);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setAutoEqError(null);
|
||||
if (opening) ensureEntries();
|
||||
}}
|
||||
>
|
||||
<Search size={13} />
|
||||
<span>{t('settings.eqAutoEqTitle')}</span>
|
||||
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
|
||||
{autoEqOpen && (
|
||||
<div className="eq-autoeq-body">
|
||||
<div className="eq-autoeq-search-row">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('settings.eqAutoEqPlaceholder')}
|
||||
value={autoEqQuery}
|
||||
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
|
||||
autoFocus
|
||||
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
{autoEqQuery && (
|
||||
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(entriesLoading || autoEqLoading) && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
|
||||
)}
|
||||
{autoEqError && (
|
||||
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
|
||||
)}
|
||||
{autoEqApplied && (
|
||||
<div className="eq-autoeq-status eq-autoeq-applied">✓ {autoEqApplied}</div>
|
||||
)}
|
||||
|
||||
{autoEqResults.length > 0 && (
|
||||
<div className="eq-autoeq-results">
|
||||
{autoEqResults.map((r, i) => (
|
||||
<button
|
||||
key={`${r.name}|${r.source}|${i}`}
|
||||
className="eq-autoeq-result-btn"
|
||||
onClick={() => applyAutoEqResult(r)}
|
||||
>
|
||||
<span>{r.name}</span>
|
||||
<span className="eq-autoeq-result-source">{r.source}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EQ panel */}
|
||||
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
|
||||
{/* Frequency response */}
|
||||
@@ -313,6 +482,27 @@ export default function Equalizer() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pre-gain row */}
|
||||
<div className="eq-pregain-row">
|
||||
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="eq-pregain-slider"
|
||||
min={-30} max={6} step={0.1}
|
||||
value={preGain}
|
||||
disabled={!enabled}
|
||||
onChange={e => setPreGain(parseFloat(e.target.value))}
|
||||
/>
|
||||
<span className="eq-pregain-val">
|
||||
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
|
||||
</span>
|
||||
{preGain !== 0 && (
|
||||
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -31,7 +31,7 @@ export default function PlayerBar() {
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
currentTrack, currentRadio, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
@@ -40,6 +40,8 @@ export default function PlayerBar() {
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
@@ -57,6 +59,13 @@ export default function PlayerBar() {
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
|
||||
[currentRadio?.coverArt]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
@@ -80,11 +89,24 @@ export default function PlayerBar() {
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
|
||||
onClick={() => currentTrack && toggleFullscreen()}
|
||||
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
|
||||
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
|
||||
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
|
||||
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
{currentTrack?.coverArt ? (
|
||||
{isRadio ? (
|
||||
currentRadio?.coverArt ? (
|
||||
<CachedImage
|
||||
className="player-album-art"
|
||||
src={radioCoverSrc}
|
||||
cacheKey={radioCoverKey}
|
||||
alt={currentRadio.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Cast size={20} />
|
||||
</div>
|
||||
)
|
||||
) : currentTrack?.coverArt ? (
|
||||
<CachedImage
|
||||
className="player-album-art"
|
||||
src={coverSrc}
|
||||
@@ -96,7 +118,7 @@ export default function PlayerBar() {
|
||||
<Music size={22} />
|
||||
</div>
|
||||
)}
|
||||
{currentTrack && (
|
||||
{currentTrack && !isRadio && (
|
||||
<div className="player-art-expand-hint" aria-hidden="true">
|
||||
<Maximize2 size={16} />
|
||||
</div>
|
||||
@@ -104,19 +126,19 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<MarqueeText
|
||||
text={currentTrack?.title ?? t('player.noTitle')}
|
||||
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
|
||||
className="player-track-name"
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
/>
|
||||
<MarqueeText
|
||||
text={currentTrack?.artist ?? '—'}
|
||||
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
|
||||
className="player-track-artist"
|
||||
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
</div>
|
||||
{currentTrack && (
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-star-btn"
|
||||
onClick={toggleStar}
|
||||
@@ -127,7 +149,7 @@ export default function PlayerBar() {
|
||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{currentTrack && lastfmSessionKey && (
|
||||
{currentTrack && !isRadio && lastfmSessionKey && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-love-btn"
|
||||
onClick={toggleLastfmLove}
|
||||
@@ -145,7 +167,7 @@ export default function PlayerBar() {
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -156,7 +178,7 @@ export default function PlayerBar() {
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -170,13 +192,25 @@ export default function PlayerBar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waveform Seekbar */}
|
||||
{/* Waveform Seekbar / Radio live bar */}
|
||||
<div className="player-waveform-section">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
{isRadio ? (
|
||||
<>
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lyrics Button */}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -24,6 +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
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
+145
@@ -26,6 +26,7 @@ const enTranslation = {
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internet Radio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -131,11 +132,13 @@ const enTranslation = {
|
||||
moreLabelAlbums: 'More albums on {{label}}',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorite',
|
||||
trackRating: 'Rating',
|
||||
trackDuration: 'Duration',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Columns',
|
||||
notFound: 'Album not found.',
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
@@ -353,6 +356,7 @@ const enTranslation = {
|
||||
serverFailed: 'Connection failed.',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -365,6 +369,15 @@ const enTranslation = {
|
||||
eqPresetName: 'Preset name…',
|
||||
eqDeletePreset: 'Delete preset',
|
||||
eqResetBands: 'Reset to Flat',
|
||||
eqPreGain: 'Pre-gain',
|
||||
eqResetPreGain: 'Reset pre-gain',
|
||||
eqAutoEqTitle: 'AutoEQ Headphone Lookup',
|
||||
eqAutoEqPlaceholder: 'Search headphone / IEM model…',
|
||||
eqAutoEqSearching: 'Searching…',
|
||||
eqAutoEqNoResults: 'No results found',
|
||||
eqAutoEqError: 'Search failed',
|
||||
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
|
||||
eqAutoEqFetchError: 'Failed to fetch EQ profile',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Connect with Last.fm',
|
||||
lfmConnecting: 'Waiting for authorisation…',
|
||||
@@ -690,6 +703,22 @@ const enTranslation = {
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
empty: 'No radio stations configured.',
|
||||
addStation: 'Add Station',
|
||||
editStation: 'Edit',
|
||||
deleteStation: 'Delete station',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
stationName: 'Station name…',
|
||||
streamUrl: 'Stream URL…',
|
||||
homepageUrl: 'Homepage URL (optional)',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internet Radio',
|
||||
openHomepage: 'Open homepage',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -718,6 +747,7 @@ const deTranslation = {
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internetradio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -823,11 +853,13 @@ const deTranslation = {
|
||||
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Interpret',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorit',
|
||||
trackRating: 'Bewertung',
|
||||
trackDuration: 'Dauer',
|
||||
trackTotal: 'Gesamt',
|
||||
columns: 'Spalten',
|
||||
notFound: 'Album nicht gefunden.',
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
@@ -1045,6 +1077,7 @@ const deTranslation = {
|
||||
serverFailed: 'Verbindung fehlgeschlagen.',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -1057,6 +1090,15 @@ const deTranslation = {
|
||||
eqPresetName: 'Preset-Name…',
|
||||
eqDeletePreset: 'Preset löschen',
|
||||
eqResetBands: 'Auf Flat zurücksetzen',
|
||||
eqPreGain: 'Vorverstärkung',
|
||||
eqResetPreGain: 'Vorverstärkung zurücksetzen',
|
||||
eqAutoEqTitle: 'AutoEQ Kopfhörer-Suche',
|
||||
eqAutoEqPlaceholder: 'Kopfhörer / IEM Modell suchen…',
|
||||
eqAutoEqSearching: 'Suche…',
|
||||
eqAutoEqNoResults: 'Keine Ergebnisse gefunden',
|
||||
eqAutoEqError: 'Suche fehlgeschlagen',
|
||||
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
|
||||
eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Mit Last.fm verbinden',
|
||||
lfmConnecting: 'Warte auf Bestätigung…',
|
||||
@@ -1382,6 +1424,22 @@ const deTranslation = {
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
empty: 'Keine Radiosender konfiguriert.',
|
||||
addStation: 'Sender hinzufügen',
|
||||
editStation: 'Bearbeiten',
|
||||
deleteStation: 'Sender löschen',
|
||||
confirmDelete: 'Zum Bestätigen erneut klicken',
|
||||
stationName: 'Sendername…',
|
||||
streamUrl: 'Stream-URL…',
|
||||
homepageUrl: 'Homepage-URL (optional)',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage öffnen',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1410,6 +1468,7 @@ const frTranslation = {
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Radio Internet',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
@@ -1515,11 +1574,13 @@ const frTranslation = {
|
||||
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
|
||||
trackTitle: 'Titre',
|
||||
trackArtist: 'Artiste',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favori',
|
||||
trackRating: 'Note',
|
||||
trackDuration: 'Durée',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Colonnes',
|
||||
notFound: 'Album introuvable.',
|
||||
bioModal: 'Biographie de l\'artiste',
|
||||
bioClose: 'Fermer',
|
||||
@@ -1737,6 +1798,7 @@ const frTranslation = {
|
||||
serverFailed: 'Connexion échouée.',
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Connecté',
|
||||
failed: 'Échec',
|
||||
eqTitle: 'Égaliseur',
|
||||
@@ -1749,6 +1811,15 @@ const frTranslation = {
|
||||
eqPresetName: 'Nom du préréglage…',
|
||||
eqDeletePreset: 'Supprimer le préréglage',
|
||||
eqResetBands: 'Réinitialiser à plat',
|
||||
eqPreGain: 'Pré-amplification',
|
||||
eqResetPreGain: 'Réinitialiser la pré-amplification',
|
||||
eqAutoEqTitle: 'Recherche AutoEQ',
|
||||
eqAutoEqPlaceholder: 'Rechercher un casque / IEM…',
|
||||
eqAutoEqSearching: 'Recherche…',
|
||||
eqAutoEqNoResults: 'Aucun résultat',
|
||||
eqAutoEqError: 'Échec de la recherche',
|
||||
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
|
||||
eqAutoEqFetchError: 'Impossible de charger le profil EQ',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Connecter avec Last.fm',
|
||||
lfmConnecting: 'En attente d\'autorisation…',
|
||||
@@ -2074,6 +2145,22 @@ const frTranslation = {
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
radio: {
|
||||
title: 'Radio Internet',
|
||||
empty: 'Aucune station radio configurée.',
|
||||
addStation: 'Ajouter une station',
|
||||
editStation: 'Modifier',
|
||||
deleteStation: 'Supprimer la station',
|
||||
confirmDelete: 'Cliquer à nouveau pour confirmer',
|
||||
stationName: 'Nom de la station…',
|
||||
streamUrl: 'URL du flux…',
|
||||
homepageUrl: 'URL de la page d\'accueil (optionnel)',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Radio Internet',
|
||||
openHomepage: 'Ouvrir la page d\'accueil',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2102,6 +2189,7 @@ const nlTranslation = {
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internetradio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
@@ -2207,11 +2295,13 @@ const nlTranslation = {
|
||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Artiest',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Formaat',
|
||||
trackFavorite: 'Favoriet',
|
||||
trackRating: 'Beoordeling',
|
||||
trackDuration: 'Duur',
|
||||
trackTotal: 'Totaal',
|
||||
columns: 'Kolommen',
|
||||
notFound: 'Album niet gevonden.',
|
||||
bioModal: 'Artiest biografie',
|
||||
bioClose: 'Sluiten',
|
||||
@@ -2429,6 +2519,7 @@ const nlTranslation = {
|
||||
serverFailed: 'Verbinding mislukt.',
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbonden',
|
||||
failed: 'Mislukt',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -2441,6 +2532,15 @@ const nlTranslation = {
|
||||
eqPresetName: 'Naam voorinstelling…',
|
||||
eqDeletePreset: 'Voorinstelling verwijderen',
|
||||
eqResetBands: 'Terugzetten naar vlak',
|
||||
eqPreGain: 'Voorversterking',
|
||||
eqResetPreGain: 'Voorversterking resetten',
|
||||
eqAutoEqTitle: 'AutoEQ Hoofdtelefoon Zoeken',
|
||||
eqAutoEqPlaceholder: 'Zoek hoofdtelefoon / IEM model…',
|
||||
eqAutoEqSearching: 'Zoeken…',
|
||||
eqAutoEqNoResults: 'Geen resultaten gevonden',
|
||||
eqAutoEqError: 'Zoeken mislukt',
|
||||
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
|
||||
eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Verbinden met Last.fm',
|
||||
lfmConnecting: 'Wachten op autorisatie…',
|
||||
@@ -2766,6 +2866,22 @@ const nlTranslation = {
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
empty: 'Geen radiostations geconfigureerd.',
|
||||
addStation: 'Station toevoegen',
|
||||
editStation: 'Bewerken',
|
||||
deleteStation: 'Station verwijderen',
|
||||
confirmDelete: 'Klik opnieuw om te bevestigen',
|
||||
stationName: 'Stationsnaam…',
|
||||
streamUrl: 'Stream-URL…',
|
||||
homepageUrl: 'Homepage-URL (optioneel)',
|
||||
save: 'Opslaan',
|
||||
cancel: 'Annuleren',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage openen',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2794,6 +2910,7 @@ const zhTranslation = {
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
radio: '网络电台',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -2899,11 +3016,13 @@ const zhTranslation = {
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
trackFavorite: '收藏',
|
||||
trackRating: '评分',
|
||||
trackDuration: '时长',
|
||||
trackTotal: '总计',
|
||||
columns: '列',
|
||||
notFound: '未找到专辑。',
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
@@ -3117,6 +3236,7 @@ const zhTranslation = {
|
||||
serverFailed: '连接失败。',
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: '已连接',
|
||||
failed: '失败',
|
||||
eqTitle: '均衡器',
|
||||
@@ -3129,6 +3249,15 @@ const zhTranslation = {
|
||||
eqPresetName: '预设名称…',
|
||||
eqDeletePreset: '删除预设',
|
||||
eqResetBands: '重置为平直',
|
||||
eqPreGain: '预增益',
|
||||
eqResetPreGain: '重置预增益',
|
||||
eqAutoEqTitle: 'AutoEQ 耳机查询',
|
||||
eqAutoEqPlaceholder: '搜索耳机/入耳式型号…',
|
||||
eqAutoEqSearching: '搜索中…',
|
||||
eqAutoEqNoResults: '未找到结果',
|
||||
eqAutoEqError: '搜索失败',
|
||||
eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试',
|
||||
eqAutoEqFetchError: '无法获取 EQ 配置',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: '连接 Last.fm',
|
||||
lfmConnecting: '等待授权…',
|
||||
@@ -3454,6 +3583,22 @@ const zhTranslation = {
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
empty: '未配置任何电台。',
|
||||
addStation: '添加电台',
|
||||
editStation: '编辑',
|
||||
deleteStation: '删除电台',
|
||||
confirmDelete: '再次点击确认',
|
||||
stationName: '电台名称…',
|
||||
streamUrl: '流地址…',
|
||||
homepageUrl: '主页地址(可选)',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
live: '直播',
|
||||
liveStream: '网络电台',
|
||||
openHomepage: '打开主页',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function AdvancedSearch() {
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||
{t('search.advancedGenre')}
|
||||
</span>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
|
||||
<CustomSelect
|
||||
value={genre}
|
||||
options={genreSelectOptions}
|
||||
@@ -188,7 +188,7 @@ export default function AdvancedSearch() {
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
placeholder={t('search.advancedYearFrom')}
|
||||
style={{ width: 76 }}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>–</span>
|
||||
<input
|
||||
@@ -199,7 +199,7 @@ export default function AdvancedSearch() {
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
placeholder={t('search.advancedYearTo')}
|
||||
style={{ width: 76 }}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +319,7 @@ export default function AdvancedSearch() {
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className="track-artist"
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>
|
||||
|
||||
@@ -410,12 +410,10 @@ export default function ArtistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.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">{idx + 1}</span>
|
||||
</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
|
||||
@@ -133,19 +133,17 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
<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"
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -56,7 +57,17 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
openContextMenu: s.openContextMenu,
|
||||
currentTrack: s.currentTrack,
|
||||
isPlaying: s.isPlaying,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
}))
|
||||
);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||
@@ -561,11 +572,9 @@ export default function PlaylistDetail() {
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
<React.Fragment key={song.id + idx}>
|
||||
{/* Drop indicator above row */}
|
||||
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
|
||||
<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' : ''}`}
|
||||
@@ -590,7 +599,7 @@ export default function PlaylistDetail() {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
@@ -601,11 +610,9 @@ export default function PlaylistDetail() {
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
@@ -617,7 +624,11 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* Artist */}
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<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 */}
|
||||
@@ -654,8 +665,6 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop indicator below last row or between rows */}
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
@@ -726,7 +735,11 @@ export default function PlaylistDetail() {
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<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 />
|
||||
|
||||
+10
-14
@@ -333,7 +333,7 @@ export default function RandomMix() {
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
{genreMixSongs.map(song => {
|
||||
{genreMixSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = genreMixSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
@@ -364,12 +364,10 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
{isCurrentTrack && 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>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell">
|
||||
@@ -428,7 +426,7 @@ export default function RandomMix() {
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{filteredSongs.map((song) => {
|
||||
{filteredSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = filteredSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
@@ -469,12 +467,10 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
{isCurrentTrack && 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>
|
||||
|
||||
<div className="track-info">
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
@@ -453,7 +453,7 @@ export default function Settings() {
|
||||
{/* Cache */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
@@ -847,6 +847,9 @@ export default function Settings() {
|
||||
<Server size={18} />
|
||||
<h2>{t('settings.servers')}</h2>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.serverCompatible')}
|
||||
</div>
|
||||
|
||||
{auth.servers.length === 0 && !showAddForm ? (
|
||||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
@@ -876,7 +879,7 @@ export default function Settings() {
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => testConnection(srv)}
|
||||
disabled={status === 'testing'}
|
||||
@@ -915,7 +918,7 @@ export default function Settings() {
|
||||
{showAddForm ? (
|
||||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<button className="btn btn-surface" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
@@ -993,7 +996,7 @@ export default function Settings() {
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
@@ -1060,7 +1063,7 @@ export default function Settings() {
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+25
-7
@@ -37,19 +37,22 @@ export const BUILTIN_PRESETS: EqPreset[] = [
|
||||
interface EqState {
|
||||
gains: number[]; // 10 values, -12 to +12 dB
|
||||
enabled: boolean;
|
||||
preGain: number; // pre-amplification in dB (-30 to +6), applied before bands
|
||||
activePreset: string | null;
|
||||
customPresets: EqPreset[];
|
||||
|
||||
setBandGain: (index: number, gain: number) => void;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setPreGain: (v: number) => void;
|
||||
applyPreset: (name: string) => void;
|
||||
applyAutoEq: (name: string, gains: number[], preGain: number) => void;
|
||||
saveCustomPreset: (name: string) => void;
|
||||
deleteCustomPreset: (name: string) => void;
|
||||
syncToRust: () => void;
|
||||
}
|
||||
|
||||
function syncEq(gains: number[], enabled: boolean) {
|
||||
invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {});
|
||||
function syncEq(gains: number[], enabled: boolean, preGain: number) {
|
||||
invoke('audio_set_eq', { gains: gains.map(g => g), enabled, preGain }).catch(() => {});
|
||||
}
|
||||
|
||||
export const useEqStore = create<EqState>()(
|
||||
@@ -57,6 +60,7 @@ export const useEqStore = create<EqState>()(
|
||||
(set, get) => ({
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
enabled: false,
|
||||
preGain: 0,
|
||||
activePreset: 'Flat',
|
||||
customPresets: [],
|
||||
|
||||
@@ -65,12 +69,25 @@ export const useEqStore = create<EqState>()(
|
||||
const gains = [...get().gains];
|
||||
gains[index] = clamped;
|
||||
set({ gains, activePreset: null });
|
||||
syncEq(gains, get().enabled);
|
||||
syncEq(gains, get().enabled, get().preGain);
|
||||
},
|
||||
|
||||
setEnabled: (v) => {
|
||||
set({ enabled: v });
|
||||
syncEq(get().gains, v);
|
||||
syncEq(get().gains, v, get().preGain);
|
||||
},
|
||||
|
||||
setPreGain: (v) => {
|
||||
const clamped = Math.max(-30, Math.min(6, v));
|
||||
set({ preGain: clamped, activePreset: null });
|
||||
syncEq(get().gains, get().enabled, clamped);
|
||||
},
|
||||
|
||||
applyAutoEq: (name, gains, preGain) => {
|
||||
const clampedPreGain = Math.max(-30, Math.min(6, preGain));
|
||||
const clampedGains = gains.map(g => Math.max(-12, Math.min(12, g)));
|
||||
set({ gains: clampedGains, preGain: clampedPreGain, activePreset: name });
|
||||
syncEq(clampedGains, get().enabled, clampedPreGain);
|
||||
},
|
||||
|
||||
applyPreset: (name) => {
|
||||
@@ -78,7 +95,7 @@ export const useEqStore = create<EqState>()(
|
||||
const preset = all.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
set({ gains: [...preset.gains], activePreset: name });
|
||||
syncEq(preset.gains, get().enabled);
|
||||
syncEq(preset.gains, get().enabled, get().preGain);
|
||||
},
|
||||
|
||||
saveCustomPreset: (name) => {
|
||||
@@ -95,8 +112,8 @@ export const useEqStore = create<EqState>()(
|
||||
},
|
||||
|
||||
syncToRust: () => {
|
||||
const { gains, enabled } = get();
|
||||
syncEq(gains, enabled);
|
||||
const { gains, enabled, preGain } = get();
|
||||
syncEq(gains, enabled, preGain);
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -105,6 +122,7 @@ export const useEqStore = create<EqState>()(
|
||||
partialize: (s) => ({
|
||||
gains: s.gains,
|
||||
enabled: s.enabled,
|
||||
preGain: s.preGain,
|
||||
activePreset: s.activePreset,
|
||||
customPresets: s.customPresets,
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -60,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
@@ -73,6 +74,7 @@ interface PlayerState {
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
@@ -262,6 +264,13 @@ function handleAudioEnded() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Radio stream disconnected — just stop; don't advance queue.
|
||||
if (usePlayerStore.getState().currentRadio) {
|
||||
isAudioPaused = false;
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||
isAudioPaused = false;
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
@@ -481,6 +490,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
currentRadio: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
@@ -572,7 +582,31 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
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 });
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
||||
},
|
||||
|
||||
// ── playRadio ────────────────────────────────────────────────────────────
|
||||
playRadio: (station) => {
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
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);
|
||||
set({ isPlaying: false, currentRadio: null });
|
||||
});
|
||||
set({
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: true, // no scrobbling for radio
|
||||
});
|
||||
},
|
||||
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
@@ -42,6 +43,16 @@ export const useSidebarStore = create<SidebarStore>()(
|
||||
|
||||
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
|
||||
}),
|
||||
{ name: 'psysonic_sidebar' }
|
||||
{
|
||||
name: 'psysonic_sidebar',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
const known = new Set(state.items.map(i => i.id));
|
||||
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
|
||||
if (missing.length > 0) {
|
||||
state.items = [...state.items, ...missing];
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
+339
-43
@@ -1217,6 +1217,7 @@
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
@@ -1225,7 +1226,6 @@
|
||||
|
||||
.tracklist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1238,13 +1238,8 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.tracklist-header.tracklist-va {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1252,65 +1247,156 @@
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.track-row.track-row-va {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
contain: layout style;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tracklist-total {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-va {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total-label {
|
||||
grid-column: 1 / 5;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tracklist-total-value {
|
||||
grid-column: 5 / 6;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-va .tracklist-total-label {
|
||||
grid-column: 1 / 6;
|
||||
/* ── Column resize handle ── */
|
||||
/* Sits at the right edge of each header cell, fully inside the cell bounds */
|
||||
.col-resize-handle {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-va .tracklist-total-value {
|
||||
grid-column: 6 / 7;
|
||||
.col-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.col-resize-handle:hover::after,
|
||||
.col-resize-handle:active::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Column visibility picker ── */
|
||||
.tracklist-col-picker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
min-width: 160px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
padding: 4px 8px 6px;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-check {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Playlist tracklist variant — adds delete column ── */
|
||||
.tracklist-header.tracklist-playlist,
|
||||
.track-row.tracklist-playlist,
|
||||
.tracklist-total.tracklist-playlist {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-label {
|
||||
grid-column: 1 / 6;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-value {
|
||||
grid-column: 6 / 7;
|
||||
.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 */
|
||||
@@ -1392,7 +1478,7 @@
|
||||
}
|
||||
.track-num .bulk-check {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
@@ -1406,6 +1492,44 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Default: show track number */
|
||||
.track-num-number {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Play icon: hidden by default */
|
||||
.track-num-play {
|
||||
display: none;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Eq-bars wrapper */
|
||||
.track-num-eq {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Hover (non-active row): hide number, show play icon ── */
|
||||
.track-row:hover .track-num-number { display: none; }
|
||||
.track-row:hover .track-num-play { display: flex; }
|
||||
|
||||
/* ── Active (currently playing): hide number, show eq-bars ── */
|
||||
.track-num.track-num-active .track-num-number { display: none; }
|
||||
.track-num.track-num-active .track-num-number { color: var(--accent); } /* kept for when visible */
|
||||
|
||||
/* ── Active + hover: hide eq-bars, show play icon ── */
|
||||
.track-row:hover .track-num.track-num-active .track-num-eq { display: none; }
|
||||
.track-row:hover .track-num.track-num-active .track-num-play { display: flex; }
|
||||
|
||||
/* Paused state: show play icon statically (no hover needed) */
|
||||
.track-num.track-num-paused .track-num-play { display: flex; opacity: 0.5; }
|
||||
.track-row:hover .track-num.track-num-paused .track-num-play { opacity: 1; }
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
display: flex;
|
||||
@@ -1478,11 +1602,6 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.track-num {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
min-width: 0;
|
||||
@@ -1510,6 +1629,11 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist-link:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.track-codec {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
@@ -1542,6 +1666,14 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.track-genre {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-star-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -2281,10 +2413,18 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.toggle-switch:hover .toggle-track {
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked+.toggle-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch:hover input:checked+.toggle-track {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked+.toggle-track::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
@@ -2507,10 +2647,10 @@
|
||||
/* ── Blurred background ── */
|
||||
.fs-bg {
|
||||
position: absolute;
|
||||
inset: -30%;
|
||||
inset: -20%;
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
filter: blur(6px) brightness(0.25) saturate(1.6);
|
||||
filter: blur(4px) brightness(0.25) saturate(1.5);
|
||||
animation: ken-burns 120s linear infinite;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
@@ -2561,6 +2701,7 @@
|
||||
gap: 18px;
|
||||
width: min(440px, 88vw);
|
||||
padding: 16px 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Artist name — above cover */
|
||||
@@ -2584,6 +2725,7 @@
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
animation: cover-breathe 9s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.fs-cover {
|
||||
@@ -4354,6 +4496,134 @@
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pre-gain row */
|
||||
.eq-pregain-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.eq-pregain-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.eq-pregain-slider {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eq-pregain-val {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* AutoEQ section */
|
||||
.eq-autoeq-section {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eq-autoeq-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eq-autoeq-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eq-autoeq-toggle span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.eq-autoeq-body {
|
||||
padding: 10px 12px 12px;
|
||||
background: var(--bg-app);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eq-autoeq-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.eq-autoeq-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.eq-autoeq-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.eq-autoeq-applied {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eq-autoeq-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.eq-autoeq-result-btn {
|
||||
text-align: left;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eq-autoeq-result-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eq-autoeq-result-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eq-autoeq-result-source {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Changelog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.changelog-list {
|
||||
@@ -4668,6 +4938,32 @@
|
||||
to { background: #ff2222; }
|
||||
}
|
||||
|
||||
/* ─ Internet Radio ─ */
|
||||
.radio-card-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.radio-live-overlay {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.radio-live-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: #e03030;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
|
||||
+12
-4
@@ -1,5 +1,14 @@
|
||||
/* ─── Psysonic App Shell ─── */
|
||||
|
||||
*, *::before, *::after {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection only where the user needs to copy content */
|
||||
.artist-bio, [data-selectable] {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -141,18 +150,17 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Now Playing nav link */
|
||||
/* Now Playing nav link — subtly accented but not permanently "active"-looking */
|
||||
.nav-link-nowplaying {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-link-nowplaying:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-link-nowplaying.active {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-link-nowplaying svg {
|
||||
|
||||
@@ -3670,10 +3670,12 @@ body.psy-dragging * {
|
||||
.btn-surface {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.btn-surface:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
/* ─── Input ─── */
|
||||
|
||||
Reference in New Issue
Block a user