diff --git a/README.md b/README.md
index c36cc5e4..94c6b63e 100644
--- a/README.md
+++ b/README.md
@@ -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.
-
+
## ✨ Features
diff --git a/public/icon.png b/public/icon.png
deleted file mode 100644
index f5d7e393..00000000
Binary files a/public/icon.png and /dev/null differ
diff --git a/public/logo.png b/public/logo.png
deleted file mode 100644
index 25b372ed..00000000
Binary files a/public/logo.png and /dev/null differ
diff --git a/public/screenshot.png b/public/screenshot.png
deleted file mode 100644
index b3d45efa..00000000
Binary files a/public/screenshot.png and /dev/null differ
diff --git a/public/screenshot1.png b/public/screenshot1.png
new file mode 100644
index 00000000..218b6c82
Binary files /dev/null and b/public/screenshot1.png differ
diff --git a/public/screenshot2.png b/public/screenshot2.png
new file mode 100644
index 00000000..d23555eb
Binary files /dev/null and b/public/screenshot2.png differ
diff --git a/public/screenshot3.png b/public/screenshot3.png
new file mode 100644
index 00000000..cf02416b
Binary files /dev/null and b/public/screenshot3.png differ
diff --git a/public/screenshot4.png b/public/screenshot4.png
new file mode 100644
index 00000000..c181f1cd
Binary files /dev/null and b/public/screenshot4.png differ
diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json
index 2b3dd293..6d8d1eec 100644
--- a/src-tauri/gen/schemas/capabilities.json
+++ b/src-tauri/gen/schemas/capabilities.json
@@ -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"]}}
\ No newline at end of file
+{"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"]}}
\ No newline at end of file
diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx
index 94d8d78f..d768e306 100644
--- a/src/components/AlbumCard.tsx
+++ b/src/components/AlbumCard.tsx
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
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 { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -11,9 +11,12 @@ import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
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 openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -25,20 +28,26 @@ function AlbumCard({ album }: AlbumCardProps) {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
+ const handleClick = () => {
+ if (selectionMode) { onToggleSelect?.(album.id); return; }
+ navigate(`/album/${album.id}`);
+ };
+
return (
navigate(`/album/${album.id}`)}
+ className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
+ onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
- onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
+ onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
+ if (selectionMode) { e.preventDefault(); return; }
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
onMouseDown={e => {
- if (e.button !== 0) return;
+ if (selectionMode || e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
@@ -64,20 +73,27 @@ function AlbumCard({ album }: AlbumCardProps) {
)}
- {isOffline && (
+ {isOffline && !selectionMode && (
)}
-
-
-
+ {selectionMode && (
+
+ {selected && }
+
+ )}
+ {!selectionMode && (
+
+
+
+ )}
{album.name}
diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx
index 56ca76ce..0f8d1cf9 100644
--- a/src/components/WaveformSeek.tsx
+++ b/src/components/WaveformSeek.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
+import { useAuthStore, type SeekbarStyle } from '../store/authStore';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
@@ -7,6 +8,43 @@ function fmt(s: number): string {
}
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 {
let h = 0x811c9dc5;
@@ -17,108 +55,313 @@ function hashStr(str: string): number {
return h;
}
-function makeHeights(trackId: string): Float32Array {
+export function makeHeights(trackId: string): Float32Array {
let s = hashStr(trackId);
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
h[i] = s / 0xffffffff;
}
- // Smooth for an organic look
for (let pass = 0; pass < 5; pass++) {
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;
}
}
- // Normalize to [0.12, 1.0]
let max = 0;
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
- if (max > 0) {
- for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
- }
+ if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
return h;
}
+// ── draw functions ────────────────────────────────────────────────────────────
+
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
progress: number,
buffered: number,
) {
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- const rect = canvas.getBoundingClientRect();
- 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';
+ const r = setupCanvas(canvas);
+ if (!r) return;
+ const { ctx, w, h } = r;
+ const { played, buffered: buffCol, unplayed } = getColors();
if (!heights) {
ctx.globalAlpha = 0.3;
- ctx.fillStyle = colorUnplayed;
+ ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2);
ctx.globalAlpha = 1;
return;
}
- // Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
- // Pass 1 — unplayed (dim)
ctx.globalAlpha = 0.28;
- ctx.fillStyle = colorUnplayed;
+ ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
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);
- 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.fillStyle = colorBuffered;
+ ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
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);
- 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) {
ctx.globalAlpha = 1;
- ctx.fillStyle = colorAccent;
- ctx.shadowColor = colorAccent;
+ ctx.fillStyle = played;
+ ctx.shadowColor = played;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
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);
- ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
+ ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
ctx.shadowBlur = 0;
}
-
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
(null);
+ const rafRef = useRef(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 (
+
+ );
+}
+
+// ── main component ────────────────────────────────────────────────────────────
+
interface Props {
trackId: string | undefined;
}
@@ -132,10 +375,11 @@ export default function WaveformSeek({ trackId }: Props) {
const [hoverPct, setHoverPct] = useState(null);
- const progress = usePlayerStore(s => s.progress);
- const buffered = usePlayerStore(s => s.buffered);
- const seek = usePlayerStore(s => s.seek);
- const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
+ const progress = usePlayerStore(s => s.progress);
+ const buffered = usePlayerStore(s => s.buffered);
+ const seek = usePlayerStore(s => s.seek);
+ const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
+ const seekbarStyle = useAuthStore(s => s.seekbarStyle);
progressRef.current = progress;
bufferedRef.current = buffered;
@@ -146,19 +390,19 @@ export default function WaveformSeek({ trackId }: Props) {
useEffect(() => {
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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(() => {
- drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
+ drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
});
ro.observe(canvas);
return () => ro.disconnect();
- }, []);
+ }, [seekbarStyle]);
const trackIdRef = useRef(trackId);
trackIdRef.current = trackId;
diff --git a/src/locales/de.ts b/src/locales/de.ts
index c0e48a3b..f36dc501 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -251,6 +251,17 @@ export const deTranslation = {
yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen',
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: {
title: 'Künstler',
@@ -551,6 +562,13 @@ export const deTranslation = {
infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
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: {
modalTitle: 'Was ist neu',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 721dec3f..8a139acd 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -252,6 +252,17 @@ export const enTranslation = {
yearTo: 'To',
yearFilterClear: 'Clear year filter',
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: {
title: 'Artists',
@@ -552,6 +563,13 @@ export const enTranslation = {
infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
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: {
modalTitle: "What's New",
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index 75389410..8cb6ec73 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -251,6 +251,17 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre 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: {
title: 'Artistes',
@@ -549,6 +560,13 @@ export const frTranslation = {
infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
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: {
modalTitle: 'Quoi de neuf',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index 4a4f0587..7b3e5bf9 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -250,7 +250,18 @@ export const nbTranslation = {
yearFrom: 'Fra',
yearTo: 'Til',
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: {
title: 'Artister',
@@ -548,6 +559,13 @@ export const nbTranslation = {
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
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: {
modalTitle: "Nyheter",
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index cf59b459..852e33d9 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -251,6 +251,17 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
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: {
title: 'Artiesten',
@@ -549,6 +560,13 @@ export const nlTranslation = {
infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel',
+ seekbarStyle: 'Zoekbalkstijl',
+ seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
+ seekbarWaveform: 'Golfvorm',
+ seekbarLinedot: 'Lijn & punt',
+ seekbarBar: 'Balk',
+ seekbarThick: 'Dikke balk',
+ seekbarSegmented: 'Gesegmenteerd',
},
changelog: {
modalTitle: 'Wat is nieuw',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index cf9ec20c..f1872032 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -260,6 +260,17 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
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: {
title: 'Исполнители',
@@ -575,6 +586,13 @@ export const ruTranslation = {
infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально',
+ seekbarStyle: 'Стиль прогресс-бара',
+ seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
+ seekbarWaveform: 'Форма волны',
+ seekbarLinedot: 'Линия и точка',
+ seekbarBar: 'Полоса',
+ seekbarThick: 'Толстая полоса',
+ seekbarSegmented: 'Сегменты',
},
changelog: {
modalTitle: 'Что нового',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 35b6e3c8..cf5d9cb7 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -251,6 +251,17 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
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: {
title: '艺术家',
@@ -545,6 +556,13 @@ export const zhTranslation = {
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
+ seekbarStyle: '进度条样式',
+ seekbarStyleDesc: '选择播放进度条的外观',
+ seekbarWaveform: '波形',
+ seekbarLinedot: '线条与点',
+ seekbarBar: '条形',
+ seekbarThick: '粗条形',
+ seekbarSegmented: '分段式',
},
changelog: {
modalTitle: '新功能',
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx
index dcb920ef..d04afb0d 100644
--- a/src/pages/Albums.tsx
+++ b/src/pages/Albums.tsx
@@ -1,16 +1,25 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
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 { 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';
const PAGE_SIZE = 30;
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 {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set();
@@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise {
export default function Albums() {
const { t } = useTranslation();
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([]);
const [sort, setSort] = useState('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -30,6 +44,73 @@ export default function Albums() {
const [yearTo, setYearTo] = useState('');
const observerTarget = useRef(null);
+ // ── Multi-selection ──────────────────────────────────────────────────────
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(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 fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10);
@@ -108,58 +189,87 @@ export default function Albums() {
return (
-
{t('albums.title')}
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('albums.title')}
+
- {!yearActive && sortOptions.map(o => (
-
- ))}
-
- {/* Year range filter */}
-
-
- {t('albums.yearFilterLabel')}
-
- setYearFrom(e.target.value)}
- style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
- />
- –
- setYearTo(e.target.value)}
- style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
- />
- {yearActive && (
-
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+ <>
+ {!yearActive && sortOptions.map(o => (
+
setSort(o.value)}
+ style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
+ >
+ {o.label}
+
+ ))}
-
+
+
+ {t('albums.yearFilterLabel')}
+
+ setYearFrom(e.target.value)}
+ style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
+ />
+ –
+ setYearTo(e.target.value)}
+ style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
+ />
+ {yearActive && (
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
+
@@ -170,7 +280,15 @@ export default function Albums() {
) : (
<>
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
{!genreFiltered && (
@@ -179,6 +297,7 @@ export default function Albums() {
)}
>
)}
+
);
}
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx
index e896a54c..44ce8dde 100644
--- a/src/pages/NewReleases.tsx
+++ b/src/pages/NewReleases.tsx
@@ -1,12 +1,22 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
+import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
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 { 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;
+function sanitizeFilename(name: string): string {
+ return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
+}
+
async function fetchByGenres(genres: string[]): Promise
{
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set();
@@ -17,6 +27,11 @@ async function fetchByGenres(genres: string[]): Promise {
export default function NewReleases() {
const { t } = useTranslation();
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([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -25,6 +40,53 @@ export default function NewReleases() {
const observerTarget = useRef(null);
const filtered = selectedGenres.length > 0;
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(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) => {
setLoading(true);
try {
@@ -71,8 +133,37 @@ export default function NewReleases() {
return (
-
{t('sidebar.newReleases')}
-
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('sidebar.newReleases')}
+
+
+ {selectionMode && selectedIds.size > 0 ? (
+ <>
+
+
+ {t('albums.addOffline')}
+
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+
+ )}
+
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
+
+
{loading && albums.length === 0 ? (
@@ -82,7 +173,15 @@ export default function NewReleases() {
) : (
<>
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
{!filtered && (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index e4d2f220..281dfb9f 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -1,11 +1,16 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
-import { RefreshCw } from 'lucide-react';
-import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
+import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
+import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
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;
/** 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). */
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
{
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set();
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--) {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
@@ -29,16 +37,67 @@ async function fetchByGenres(genres: string[]): Promise {
export default function RandomAlbums() {
const { t } = useTranslation();
- const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
- const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
- const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
- const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
+ const auth = useAuthStore();
+ const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
+ const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
+ 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([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState([]);
const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0;
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(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[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
@@ -70,17 +129,46 @@ export default function RandomAlbums() {
return (
-
{t('randomAlbums.title')}
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('randomAlbums.title')}
+
-
+ {selectionMode && selectedIds.size > 0 ? (
+ <>
+
+
+ {t('albums.addOffline')}
+
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+ <>
+
+ load(selectedGenres)}
+ disabled={loading}
+ data-tooltip={t('randomAlbums.refresh')}
+ >
+
+ {t('randomAlbums.refresh')}
+
+ >
+ )}
load(selectedGenres)}
- disabled={loading}
- data-tooltip={t('randomAlbums.refresh')}
+ 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)' } : {}}
>
-
- {t('randomAlbums.refresh')}
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
@@ -91,7 +179,15 @@ export default function RandomAlbums() {
) : (
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
)}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 75955615..0227bd33 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -18,7 +18,8 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
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 { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
@@ -1253,6 +1254,29 @@ export default function Settings() {
+
+
+
+
{t('settings.seekbarStyle')}
+
+
+
+ {t('settings.seekbarStyleDesc')}
+
+
+ {(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => (
+ auth.setSeekbarStyle(style)}
+ />
+ ))}
+
+
+
+
>
)}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 3034f7b2..c91736b1 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -12,6 +12,8 @@ export interface ServerProfile {
password: string;
}
+export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented';
+
interface AuthState {
// Multi-server
servers: ServerProfile[];
@@ -50,6 +52,8 @@ interface AuthState {
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
+ seekbarStyle: SeekbarStyle;
+
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
@@ -143,6 +147,7 @@ interface AuthState {
setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
+ setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
@@ -228,6 +233,7 @@ export const useAuthStore = create()(
showFullscreenLyrics: true,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
+ seekbarStyle: 'waveform',
enableHiRes: false,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
@@ -324,6 +330,7 @@ export const useAuthStore = create()(
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+ setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
diff --git a/src/styles/components.css b/src/styles/components.css
index ea6bf0a6..91e17b70 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -496,6 +496,76 @@
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 {
padding: var(--space-3) var(--space-4) var(--space-4);
diff --git a/src/utils/backup.ts b/src/utils/backup.ts
index 97a4e212..e6331548 100644
--- a/src/utils/backup.ts
+++ b/src/utils/backup.ts
@@ -14,6 +14,7 @@ const BACKUP_KEYS = [
'psysonic-eq',
'psysonic_global_shortcuts',
'psysonic-player',
+ 'psysonic_home',
];
export async function exportBackup(): Promise {