merge: integrate origin/main into feat/improve-rating-ux

Resolve conflicts in RandomAlbums (keep mix rating filter with batch ZIP/offline
selection from upstream) and Settings (SeekbarPreview + mix filter constants).
This commit is contained in:
Maxim Isaev
2026-04-08 05:04:12 +03:00
25 changed files with 946 additions and 144 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Psysonic is a beautiful desktop music player built completely from the ground up
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library. Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png) ![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features ## ✨ Features
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}} {"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
+32 -16
View File
@@ -1,6 +1,6 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, HardDriveDownload } from 'lucide-react'; import { Play, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
@@ -11,9 +11,12 @@ import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps { interface AlbumCardProps {
album: SubsonicAlbum; album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
} }
function AlbumCard({ album }: AlbumCardProps) { function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -25,20 +28,26 @@ function AlbumCard({ album }: AlbumCardProps) {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const handleClick = () => {
if (selectionMode) { onToggleSelect?.(album.id); return; }
navigate(`/album/${album.id}`);
};
return ( return (
<div <div
className="album-card card" className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
onClick={() => navigate(`/album/${album.id}`)} onClick={handleClick}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${album.name} von ${album.artist}`} aria-label={`${album.name} von ${album.artist}`}
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)} onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => { onContextMenu={(e) => {
if (selectionMode) { e.preventDefault(); return; }
e.preventDefault(); e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album'); openContextMenu(e.clientX, e.clientY, album, 'album');
}} }}
onMouseDown={e => { onMouseDown={e => {
if (e.button !== 0) return; if (selectionMode || e.button !== 0) return;
e.preventDefault(); e.preventDefault();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => { const onMove = (me: MouseEvent) => {
@@ -64,20 +73,27 @@ function AlbumCard({ album }: AlbumCardProps) {
</svg> </svg>
</div> </div>
)} )}
{isOffline && ( {isOffline && !selectionMode && (
<div className="album-card-offline-badge" aria-label="Offline available"> <div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} /> <HardDriveDownload size={12} />
</div> </div>
)} )}
<div className="album-card-play-overlay"> {selectionMode && (
<button <div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
className="album-card-details-btn" {selected && <Check size={14} strokeWidth={3} />}
onClick={e => { e.stopPropagation(); playAlbum(album.id); }} </div>
aria-label={`${album.name} abspielen`} )}
> {!selectionMode && (
<Play size={15} fill="currentColor" /> <div className="album-card-play-overlay">
</button> <button
</div> className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
)}
</div> </div>
<div className="album-card-info"> <div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p> <p className="album-card-title truncate">{album.name}</p>
+296 -52
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
function fmt(s: number): string { function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00'; if (!s || isNaN(s)) return '0:00';
@@ -7,6 +8,43 @@ function fmt(s: number): string {
} }
const BAR_COUNT = 500; const BAR_COUNT = 500;
const SEG_COUNT = 60;
// ── color helper ──────────────────────────────────────────────────────────────
function getColors() {
const s = getComputedStyle(document.documentElement);
return {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
};
}
// ── canvas setup ──────────────────────────────────────────────────────────────
function setupCanvas(
canvas: HTMLCanvasElement,
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
// ── waveform heights ──────────────────────────────────────────────────────────
function hashStr(str: string): number { function hashStr(str: string): number {
let h = 0x811c9dc5; let h = 0x811c9dc5;
@@ -17,108 +55,313 @@ function hashStr(str: string): number {
return h; return h;
} }
function makeHeights(trackId: string): Float32Array { export function makeHeights(trackId: string): Float32Array {
let s = hashStr(trackId); let s = hashStr(trackId);
const h = new Float32Array(BAR_COUNT); const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0; s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
h[i] = s / 0xffffffff; h[i] = s / 0xffffffff;
} }
// Smooth for an organic look
for (let pass = 0; pass < 5; pass++) { for (let pass = 0; pass < 5; pass++) {
for (let i = 1; i < BAR_COUNT - 1; i++) { for (let i = 1; i < BAR_COUNT - 1; i++) {
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25; h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
} }
} }
// Normalize to [0.12, 1.0]
let max = 0; let max = 0;
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i]; for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
if (max > 0) { if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
}
return h; return h;
} }
// ── draw functions ────────────────────────────────────────────────────────────
function drawWaveform( function drawWaveform(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
heights: Float32Array | null, heights: Float32Array | null,
progress: number, progress: number,
buffered: number, buffered: number,
) { ) {
const ctx = canvas.getContext('2d'); const r = setupCanvas(canvas);
if (!ctx) return; if (!r) return;
const { ctx, w, h } = r;
const rect = canvas.getBoundingClientRect(); const { played, buffered: buffCol, unplayed } = getColors();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement);
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
if (!heights) { if (!heights) {
ctx.globalAlpha = 0.3; ctx.globalAlpha = 0.3;
ctx.fillStyle = colorUnplayed; ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2); ctx.fillRect(0, (h - 2) / 2, w, 2);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
return; return;
} }
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
const x1Of = (i: number) => (i / BAR_COUNT) * w; const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
// Pass 1 — unplayed (dim)
ctx.globalAlpha = 0.28; ctx.globalAlpha = 0.28;
ctx.fillStyle = colorUnplayed; ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue; if (i / BAR_COUNT < buffered) continue;
const barH = Math.max(1, heights[i] * h); const bh = Math.max(1, heights[i] * h);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
// Pass 2 — buffered (slightly brighter)
ctx.globalAlpha = 0.45; ctx.globalAlpha = 0.45;
ctx.fillStyle = colorBuffered; ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT; const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue; if (frac < progress || frac >= buffered) continue;
const barH = Math.max(1, heights[i] * h); const bh = Math.max(1, heights[i] * h);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
// Pass 3 — played (accent color + glow)
if (progress > 0) { if (progress > 0) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = colorAccent; ctx.fillStyle = played;
ctx.shadowColor = colorAccent; ctx.shadowColor = played;
ctx.shadowBlur = 5; ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break; if (i / BAR_COUNT >= progress) break;
const barH = Math.max(1, heights[i] * h); const bh = Math.max(1, heights[i] * h);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
}
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = 4;
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = Math.min(14, h);
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.25;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const gap = 2;
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
const segH = h * 0.65;
const y = (h - segH) / 2;
const playedIdx = Math.floor(progress * SEG_COUNT);
for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT;
const x = i * (segW + gap);
ctx.shadowBlur = 0;
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
ctx.shadowBlur = 5;
}
} else if (frac < buffered) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
} else {
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
}
ctx.beginPath();
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill();
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
// ── dispatcher ────────────────────────────────────────────────────────────────
export function drawSeekbar(
canvas: HTMLCanvasElement,
style: SeekbarStyle,
heights: Float32Array | null,
progress: number,
buffered: number,
) {
switch (style) {
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
case 'linedot': drawLineDot(canvas, progress, buffered); break;
case 'bar': drawBar(canvas, progress, buffered); break;
case 'thick': drawThick(canvas, progress, buffered); break;
case 'segmented': drawSegmented(canvas, progress, buffered); break;
}
}
// ── SeekbarPreview (animated, for Settings) ───────────────────────────────────
export function SeekbarPreview({
style,
label,
selected,
onClick,
}: {
style: SeekbarStyle;
label: string;
selected: boolean;
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
let t = 0;
const tick = () => {
t += 0.012;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
}, [style]);
return (
<button
onClick={onClick}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--ctp-base))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'stretch',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<canvas
ref={canvasRef}
style={{ width: '100%', height: 24, display: 'block' }}
/>
<span style={{
fontSize: 11,
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
textAlign: 'center',
fontWeight: selected ? 600 : 400,
}}>
{label}
</span>
</button>
);
}
// ── main component ────────────────────────────────────────────────────────────
interface Props { interface Props {
trackId: string | undefined; trackId: string | undefined;
} }
@@ -132,10 +375,11 @@ export default function WaveformSeek({ trackId }: Props) {
const [hoverPct, setHoverPct] = useState<number | null>(null); const [hoverPct, setHoverPct] = useState<number | null>(null);
const progress = usePlayerStore(s => s.progress); const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered); const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek); const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
progressRef.current = progress; progressRef.current = progress;
bufferedRef.current = buffered; bufferedRef.current = buffered;
@@ -146,19 +390,19 @@ export default function WaveformSeek({ trackId }: Props) {
useEffect(() => { useEffect(() => {
if (canvasRef.current) { if (canvasRef.current) {
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered); drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
} }
}, [progress, buffered, trackId]); }, [progress, buffered, trackId, seekbarStyle]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
}); });
ro.observe(canvas); ro.observe(canvas);
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, [seekbarStyle]);
const trackIdRef = useRef(trackId); const trackIdRef = useRef(trackId);
trackIdRef.current = trackId; trackIdRef.current = trackId;
+18
View File
@@ -251,6 +251,17 @@ export const deTranslation = {
yearTo: 'Bis', yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen', yearFilterClear: 'Jahresfilter zurücksetzen',
yearFilterLabel: 'Jahr', yearFilterLabel: 'Jahr',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
selectionCount: '{{count}} ausgewählt',
downloadZips: 'ZIPs herunterladen',
addOffline: 'Offline hinzufügen',
downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
}, },
artists: { artists: {
title: 'Künstler', title: 'Künstler',
@@ -551,6 +562,13 @@ export const deTranslation = {
infiniteQueue: 'Endlose Warteschlange', infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird', infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
experimental: 'Experimentell', experimental: 'Experimentell',
seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
seekbarWaveform: 'Wellenform',
seekbarLinedot: 'Linie & Punkt',
seekbarBar: 'Balken',
seekbarThick: 'Dicker Balken',
seekbarSegmented: 'Segmentiert',
}, },
changelog: { changelog: {
modalTitle: 'Was ist neu', modalTitle: 'Was ist neu',
+18
View File
@@ -252,6 +252,17 @@ export const enTranslation = {
yearTo: 'To', yearTo: 'To',
yearFilterClear: 'Clear year filter', yearFilterClear: 'Clear year filter',
yearFilterLabel: 'Year', yearFilterLabel: 'Year',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: 'Artists', title: 'Artists',
@@ -552,6 +563,13 @@ export const enTranslation = {
infiniteQueue: 'Infinite Queue', infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out', infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental', experimental: 'Experimental',
seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar',
seekbarWaveform: 'Waveform',
seekbarLinedot: 'Line & Dot',
seekbarBar: 'Bar',
seekbarThick: 'Thick Bar',
seekbarSegmented: 'Segmented',
}, },
changelog: { changelog: {
modalTitle: "What's New", modalTitle: "What's New",
+18
View File
@@ -251,6 +251,17 @@ export const frTranslation = {
yearTo: 'À', yearTo: 'À',
yearFilterClear: 'Effacer le filtre année', yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année', yearFilterLabel: 'Année',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: 'Artistes', title: 'Artistes',
@@ -549,6 +560,13 @@ export const frTranslation = {
infiniteQueue: 'File infinie', infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée', infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
experimental: 'Expérimental', experimental: 'Expérimental',
seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
seekbarWaveform: 'Forme d\'onde',
seekbarLinedot: 'Ligne & point',
seekbarBar: 'Barre',
seekbarThick: 'Barre épaisse',
seekbarSegmented: 'Segmentée',
}, },
changelog: { changelog: {
modalTitle: 'Quoi de neuf', modalTitle: 'Quoi de neuf',
+19 -1
View File
@@ -250,7 +250,18 @@ export const nbTranslation = {
yearFrom: 'Fra', yearFrom: 'Fra',
yearTo: 'Til', yearTo: 'Til',
yearFilterClear: 'Tøm år filteret', yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År', yearFilterLabel: 'År',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: 'Artister', title: 'Artister',
@@ -548,6 +559,13 @@ export const nbTranslation = {
preloadEarly: 'Tidlig (etter 5 s avspilling)', preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert', preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}', preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
seekbarWaveform: 'Bølgeform',
seekbarLinedot: 'Linje & punkt',
seekbarBar: 'Linje',
seekbarThick: 'Tykk linje',
seekbarSegmented: 'Segmentert',
}, },
changelog: { changelog: {
modalTitle: "Nyheter", modalTitle: "Nyheter",
+18
View File
@@ -251,6 +251,17 @@ export const nlTranslation = {
yearTo: 'Tot', yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen', yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar', yearFilterLabel: 'Jaar',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: 'Artiesten', title: 'Artiesten',
@@ -549,6 +560,13 @@ export const nlTranslation = {
infiniteQueue: 'Oneindige wachtrij', infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt', infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel', experimental: 'Experimenteel',
seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
seekbarWaveform: 'Golfvorm',
seekbarLinedot: 'Lijn & punt',
seekbarBar: 'Balk',
seekbarThick: 'Dikke balk',
seekbarSegmented: 'Gesegmenteerd',
}, },
changelog: { changelog: {
modalTitle: 'Wat is nieuw', modalTitle: 'Wat is nieuw',
+18
View File
@@ -260,6 +260,17 @@ export const ruTranslation = {
yearTo: 'По', yearTo: 'По',
yearFilterClear: 'Сбросить год', yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год', yearFilterLabel: 'Год',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: 'Исполнители', title: 'Исполнители',
@@ -575,6 +586,13 @@ export const ruTranslation = {
infiniteQueue: 'Бесконечная очередь', infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится', infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально', experimental: 'Экспериментально',
seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
seekbarWaveform: 'Форма волны',
seekbarLinedot: 'Линия и точка',
seekbarBar: 'Полоса',
seekbarThick: 'Толстая полоса',
seekbarSegmented: 'Сегменты',
}, },
changelog: { changelog: {
modalTitle: 'Что нового', modalTitle: 'Что нового',
+18
View File
@@ -251,6 +251,17 @@ export const zhTranslation = {
yearTo: '到', yearTo: '到',
yearFilterClear: '清除年份筛选', yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份', yearFilterLabel: '年份',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
}, },
artists: { artists: {
title: '艺术家', title: '艺术家',
@@ -545,6 +556,13 @@ export const zhTranslation = {
infiniteQueue: '无限队列', infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目', infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性', experimental: '实验性',
seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观',
seekbarWaveform: '波形',
seekbarLinedot: '线条与点',
seekbarBar: '条形',
seekbarThick: '粗条形',
seekbarSegmented: '分段式',
}, },
changelog: { changelog: {
modalTitle: '新功能', modalTitle: '新功能',
+171 -52
View File
@@ -1,16 +1,25 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { X } from 'lucide-react'; import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
const CURRENT_YEAR = new Date().getFullYear(); const CURRENT_YEAR = new Date().getFullYear();
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> { async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>(); const seen = new Set<string>();
@@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function Albums() { export default function Albums() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName'); const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -30,6 +44,73 @@ export default function Albums() {
const [yearTo, setYearTo] = useState(''); const [yearTo, setYearTo] = useState('');
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const url = buildDownloadUrl(album.id);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
// ── Data loading ─────────────────────────────────────────────────────────
const genreFiltered = selectedGenres.length > 0; const genreFiltered = selectedGenres.length > 0;
const fromNum = parseInt(yearFrom, 10); const fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10); const toNum = parseInt(yearTo, 10);
@@ -108,58 +189,87 @@ export default function Albums() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1> <h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{!yearActive && sortOptions.map(o => ( {selectionMode && selectedIds.size > 0 ? (
<button <>
key={o.value} <button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`} <HardDriveDownload size={15} />
onClick={() => setSort(o.value)} {t('albums.addOffline')}
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
{o.label}
</button>
))}
{/* Year range filter */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
</button> </button>
)} <button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
</div> <Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
onClick={() => setSort(o.value)}
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
{o.label}
</button>
))}
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} /> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
</button>
)}
</div>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div> </div>
</div> </div>
@@ -170,7 +280,15 @@ export default function Albums() {
) : ( ) : (
<> <>
<div className="album-grid-wrap"> <div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)} {albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div> </div>
{!genreFiltered && ( {!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}> <div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
@@ -179,6 +297,7 @@ export default function Albums() {
)} )}
</> </>
)} )}
</div> </div>
); );
} }
+103 -4
View File
@@ -1,12 +1,22 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> { async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>(); const seen = new Set<string>();
@@ -17,6 +27,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() { export default function NewReleases() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -25,6 +40,53 @@ export default function NewReleases() {
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0; const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const load = useCallback(async (offset: number, append = false) => { const load = useCallback(async (offset: number, append = false) => {
setLoading(true); setLoading(true);
try { try {
@@ -71,8 +133,37 @@ export default function NewReleases() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('sidebar.newReleases')}</h1> <h1 className="page-title" style={{ marginBottom: 0 }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} /> {selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('sidebar.newReleases')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div>
</div> </div>
{loading && albums.length === 0 ? ( {loading && albums.length === 0 ? (
@@ -82,7 +173,15 @@ export default function NewReleases() {
) : ( ) : (
<> <>
<div className="album-grid-wrap"> <div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)} {albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div> </div>
{!filtered && ( {!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}> <div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
+112 -16
View File
@@ -1,11 +1,16 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
const ALBUM_COUNT = 30; const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */ /** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -13,11 +18,14 @@ const ALBUM_FETCH_OVERSHOOT = 100;
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */ /** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
const GENRE_UNION_PREFILTER_CAP = 250; const GENRE_UNION_PREFILTER_CAP = 250;
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> { async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>(); const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }); const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
// Fisher-Yates shuffle
for (let i = union.length - 1; i > 0; i--) { for (let i = union.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]]; [union[i], union[j]] = [union[j], union[i]];
@@ -29,16 +37,67 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function RandomAlbums() { export default function RandomAlbums() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore();
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled); const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum); const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist); const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]); const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0; const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const load = useCallback(async (genres: string[]) => { const load = useCallback(async (genres: string[]) => {
if (loadingRef.current) return; if (loadingRef.current) return;
loadingRef.current = true; loadingRef.current = true;
@@ -70,17 +129,46 @@ export default function RandomAlbums() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1> <h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('randomAlbums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} /> {selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-surface"
onClick={() => load(selectedGenres)}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</>
)}
<button <button
className="btn btn-ghost" className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={() => load(selectedGenres)} onClick={toggleSelectionMode}
disabled={loading} data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip={t('randomAlbums.refresh')} data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
> >
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> <CheckSquare2 size={15} />
{t('randomAlbums.refresh')} {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button> </button>
</div> </div>
</div> </div>
@@ -91,7 +179,15 @@ export default function RandomAlbums() {
</div> </div>
) : ( ) : (
<div className="album-grid-wrap"> <div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)} {albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div> </div>
)} )}
</div> </div>
+25 -1
View File
@@ -18,7 +18,8 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker'; import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS } from '../store/authStore'; import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore'; import { useFontStore, FontId } from '../store/fontStore';
@@ -1253,6 +1254,29 @@ export default function Settings() {
</div> </div>
</section> </section>
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.seekbarStyle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.seekbarStyleDesc')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => (
<SeekbarPreview
key={style}
style={style}
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
selected={auth.seekbarStyle === style}
onClick={() => auth.setSeekbarStyle(style)}
/>
))}
</div>
</div>
</section>
<SidebarCustomizer /> <SidebarCustomizer />
</> </>
)} )}
+7
View File
@@ -12,6 +12,8 @@ export interface ServerProfile {
password: string; password: string;
} }
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented';
interface AuthState { interface AuthState {
// Multi-server // Multi-server
servers: ServerProfile[]; servers: ServerProfile[];
@@ -50,6 +52,8 @@ interface AuthState {
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
seekbarStyle: SeekbarStyle;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean; enableHiRes: boolean;
@@ -143,6 +147,7 @@ interface AuthState {
setShowFullscreenLyrics: (v: boolean) => void; setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void; setEnableHiRes: (v: boolean) => void;
setHotCacheEnabled: (v: boolean) => void; setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void; setHotCacheMaxMb: (v: number) => void;
@@ -228,6 +233,7 @@ export const useAuthStore = create<AuthState>()(
showFullscreenLyrics: true, showFullscreenLyrics: true,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
seekbarStyle: 'waveform',
enableHiRes: false, enableHiRes: false,
hotCacheEnabled: false, hotCacheEnabled: false,
hotCacheMaxMb: 256, hotCacheMaxMb: 256,
@@ -324,6 +330,7 @@ export const useAuthStore = create<AuthState>()(
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }), setEnableHiRes: (v) => set({ enableHiRes: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }), setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
+70
View File
@@ -496,6 +496,76 @@
background: var(--accent); background: var(--accent);
} }
/* ── Album card selection ── */
.album-card--selectable {
cursor: pointer;
}
.album-card--selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.album-card-select-check {
position: absolute;
top: 6px;
left: 6px;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
border: 2px solid rgba(255,255,255,0.7);
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
pointer-events: none;
}
.album-card-select-check--on {
background: var(--accent);
border-color: var(--accent);
color: var(--ctp-crust);
}
/* ── Albums selection bar (portal) ── */
.albums-selection-bar {
position: fixed;
bottom: calc(var(--player-height) + 12px);
left: 50%;
transform: translateX(-50%);
z-index: 9000;
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 8px 14px;
box-shadow: var(--shadow-lg);
white-space: nowrap;
}
.albums-selection-count {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
padding-right: 4px;
border-right: 1px solid var(--border-subtle);
}
.albums-selection-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.albums-selection-action-btn {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
.album-card-info { .album-card-info {
padding: var(--space-3) var(--space-4) var(--space-4); padding: var(--space-3) var(--space-4) var(--space-4);
+1
View File
@@ -14,6 +14,7 @@ const BACKUP_KEYS = [
'psysonic-eq', 'psysonic-eq',
'psysonic_global_shortcuts', 'psysonic_global_shortcuts',
'psysonic-player', 'psysonic-player',
'psysonic_home',
]; ];
export async function exportBackup(): Promise<string | null> { export async function exportBackup(): Promise<string | null> {