feat: Waveform seekbar, MilkDrop visualizer, EQ bars, in-app browser, and UX improvements (v1.3.0)

- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern)
- MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions
- Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix
- Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser)
- Hero/Discover deduplication: single fetch of 20 random albums split between sections
- Update checker: runs every 10 minutes during runtime; version shown without v-prefix
- Settings version: now read from package.json instead of hardcoded
- Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling
- Bump version to 1.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-15 21:24:00 +01:00
parent 9bdd433a4b
commit c91fdd7e1d
28 changed files with 800 additions and 272 deletions
+11
View File
@@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar';
import LiveSearch from './components/LiveSearch';
@@ -37,9 +39,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
}
function AppShell() {
const { t } = useTranslation();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -124,6 +128,13 @@ function AppShell() {
<LiveSearch />
<div className="spacer" />
<NowPlayingDropdown />
<button
className="collapse-btn"
onClick={toggleQueue}
title={t('player.toggleQueue')}
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
</button>
</header>
<div className="content-body" style={{ padding: 0 }}>
<Routes>
+53 -9
View File
@@ -1,12 +1,13 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
import VisualizerCanvas from './VisualizerCanvas';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -147,6 +148,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const [vizActive, setVizActive] = useState(false);
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
const [presetName, setPresetName] = useState('');
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -177,20 +182,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1 — blurred artist image (falls back to cover art) */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 2 — drifting color orbs */}
<div className="fs-orb fs-orb-1" aria-hidden="true" />
<div className="fs-orb fs-orb-2" aria-hidden="true" />
<div className="fs-orb fs-orb-3" aria-hidden="true" />
{/* Layer 1 — blurred artist image OR visualizer */}
{vizActive && currentTrack ? (
<VisualizerCanvas
trackId={currentTrack.id}
nextPresetTrigger={nextPresetTrigger}
onPresetName={setPresetName}
/>
) : (
<>
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
<div className="fs-orb fs-orb-1" aria-hidden="true" />
<div className="fs-orb fs-orb-2" aria-hidden="true" />
<div className="fs-orb fs-orb-3" aria-hidden="true" />
</>
)}
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Visualizer toggle + preset controls */}
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{vizActive && (
<>
{presetName && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{presetName}
</span>
)}
<button
className="fs-btn fs-btn-sm"
onClick={() => setNextPresetTrigger(n => n + 1)}
aria-label={t('player.nextPreset')}
data-tooltip={t('player.nextPreset')}
style={{ color: 'rgba(255,255,255,0.7)' }}
>
<Shuffle size={14} />
</button>
</>
)}
<button
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
onClick={() => setVizActive(v => !v)}
aria-label={t('player.visualizer')}
data-tooltip={t('player.visualizer')}
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
>
<AudioWaveform size={16} />
</button>
</div>
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
+7 -2
View File
@@ -42,7 +42,11 @@ function HeroBg({ url }: { url: string }) {
);
}
export default function Hero() {
interface HeroProps {
albums?: SubsonicAlbum[];
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -50,8 +54,9 @@ export default function Hero() {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, []);
}, [albumsProp]);
// Start / restart auto-advance timer
const startTimer = useCallback((len: number) => {
+69 -91
View File
@@ -1,10 +1,12 @@
import React, { useCallback, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -18,32 +20,27 @@ function formatTime(seconds: number): string {
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
} = usePlayerStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
}, [seek]);
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
}, [setVolume]);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
const progressStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`,
};
const volumeStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
};
return (
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
{/* Track Info */}
<div className="player-track-info">
<div
@@ -89,88 +86,69 @@ export default function PlayerBar() {
</div>
</div>
{/* Controls + Progress */}
<div className="player-controls">
<div className="player-buttons">
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={16} fill="currentColor" />
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={20} />
</button>
<button
className="player-btn player-btn-primary"
onClick={togglePlay}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={20} />
</button>
<button
className="player-btn"
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : 'inherit' }}
>
{repeatMode === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
</button>
</div>
<div className="player-progress">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-progress-bar">
<input
type="range"
id="player-seek"
min={0}
max={1}
step={0.001}
value={progress}
onChange={handleSeek}
style={progressStyle}
aria-label={t('player.progress')}
/>
</div>
<span className="player-time">{formatTime(duration)}</span>
</div>
</div>
{/* Volume + Connection */}
<div className="player-right">
<div className="volume-control" aria-label={t('player.volume')}>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
/>
</div>
<button
className="player-btn"
onClick={toggleQueue}
aria-label={t('player.toggleQueue')}
data-tooltip={t('player.toggleQueue')}
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
{/* Transport Controls */}
<div className="player-buttons">
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<button
className="player-btn player-btn-primary"
onClick={togglePlay}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
<List size={18} />
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className="player-btn player-btn-sm"
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
{/* Waveform Seekbar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* Volume */}
<div className="player-volume-section">
<button
className="player-btn player-btn-sm"
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
/>
</div>
</footer>
);
}
+14 -5
View File
@@ -110,6 +110,10 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
);
}
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
let _dragFromIdx: number | null = null;
export default function QueuePanel() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -154,10 +158,9 @@ export default function QueuePanel() {
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move';
// Store index in dataTransfer too — on macOS WKWebView dragend fires before
// drop, so the ref will already be null; dataTransfer survives that race.
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
@@ -179,6 +182,9 @@ export default function QueuePanel() {
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
// macOS WKWebView, so we need the value to survive into onDropQueue.
// It is cleared in onDropQueue after use instead.
};
const onDropQueue = async (e: React.DragEvent) => {
@@ -197,9 +203,11 @@ export default function QueuePanel() {
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
if (parsedData?.type === 'queue_reorder') {
// fromIdx: always reliable from dataTransfer (set during dragstart)
const fromIdx: number = parsedData.index;
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
// fromIdx: prefer dataTransfer value; fall back to module-level var for
// Windows WebView2 where getData() can return '' in the drop handler.
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
_dragFromIdx = null;
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
@@ -220,6 +228,7 @@ export default function QueuePanel() {
}
// External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return;
if (parsedData.type === 'song') {
enqueue([parsedData.track]);
+9 -4
View File
@@ -72,20 +72,25 @@ export default function Sidebar({
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
const check = async () => {
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
setLatestVersion(tag.replace(/^v/i, ''));
}
} catch {
// network unavailable — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
};
const initial = setTimeout(check, 1500);
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
}, []);
return (
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef } from 'react';
import butterchurn from 'butterchurn';
import butterchurnPresets from 'butterchurn-presets';
import { buildStreamUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
interface Props {
trackId: string;
nextPresetTrigger: number;
onPresetName: (name: string) => void;
}
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rafRef = useRef<number>(0);
const presetNamesRef = useRef<string[]>([]);
const presetMapRef = useRef<Record<string, unknown>>({});
const presetIdxRef = useRef(0);
// ── Init audio analysis + butterchurn ──────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Hidden audio element — routed through Web Audio for analysis only.
// NOT connected to AudioDestinationNode → completely silent.
// The Rust/rodio engine plays the actual audio.
const streamUrl = buildStreamUrl(trackId);
const audio = new Audio(streamUrl);
audio.crossOrigin = 'anonymous';
audioRef.current = audio;
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const source = ctx.createMediaElementSource(audio);
// Intentionally no: source.connect(ctx.destination)
// Size canvas to fill its container
const rect = canvas.getBoundingClientRect();
const w = rect.width || window.innerWidth;
const h = rect.height || window.innerHeight;
canvas.width = w * (window.devicePixelRatio || 1);
canvas.height = h * (window.devicePixelRatio || 1);
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
width: canvas.width,
height: canvas.height,
pixelRatio: window.devicePixelRatio || 1,
});
vizRef.current = visualizer;
visualizer.connectAudio(source);
// Presets
const presets = butterchurnPresets.getPresets();
const names = Object.keys(presets);
presetNamesRef.current = names;
presetMapRef.current = presets;
const startIdx = Math.floor(Math.random() * names.length);
presetIdxRef.current = startIdx;
visualizer.loadPreset(presets[names[startIdx]], 2.0);
onPresetName(names[startIdx]);
// Sync position + play state with main player
const { currentTime, isPlaying } = usePlayerStore.getState();
audio.currentTime = currentTime;
if (isPlaying) audio.play().catch(() => {});
// Render loop
const render = () => {
rafRef.current = requestAnimationFrame(render);
visualizer.render();
};
rafRef.current = requestAnimationFrame(render);
// Keep canvas sized to window
const onResize = () => {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
visualizer.setRendererSize(canvas.width, canvas.height);
};
window.addEventListener('resize', onResize);
// Sync play/pause with main player
const unsubscribe = usePlayerStore.subscribe(state => {
const a = audioRef.current;
const c = audioCtxRef.current;
if (!a || !c) return;
if (state.isPlaying) {
c.resume();
a.play().catch(() => {});
} else {
a.pause();
}
});
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', onResize);
unsubscribe();
audio.pause();
audio.src = '';
ctx.close();
vizRef.current = null;
};
}, [trackId]); // re-init on track change
// ── Next preset ────────────────────────────────────────────────────────────
useEffect(() => {
if (!nextPresetTrigger) return;
const viz = vizRef.current;
const names = presetNamesRef.current;
if (!viz || names.length === 0) return;
const next = (presetIdxRef.current + 1) % names.length;
presetIdxRef.current = next;
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
onPresetName(names[next]);
}, [nextPresetTrigger]);
return (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
);
}
+180
View File
@@ -0,0 +1,180 @@
import React, { useEffect, useRef } from 'react';
import { usePlayerStore } from '../store/playerStore';
const BAR_COUNT = 500;
function hashStr(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h = (h ^ str.charCodeAt(i)) >>> 0;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
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;
}
return h;
}
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 colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
if (!heights) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = colorUnplayed;
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;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
const barH = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
// Pass 2 — buffered (slightly brighter)
ctx.globalAlpha = 0.45;
ctx.fillStyle = colorBuffered;
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 x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
// Pass 3 — played (gradient + glow)
if (progress > 0) {
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
grad.addColorStop(0, colorBlue);
grad.addColorStop(1, colorMauve);
ctx.globalAlpha = 1;
ctx.fillStyle = grad;
ctx.shadowColor = colorMauve;
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 x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
interface Props {
trackId: string | undefined;
}
export default function WaveformSeek({ trackId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(0);
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
progressRef.current = progress;
bufferedRef.current = buffered;
useEffect(() => {
heightsRef.current = trackId ? makeHeights(trackId) : null;
}, [trackId]);
useEffect(() => {
if (canvasRef.current) {
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
}
}, [progress, buffered, trackId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(() => {
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
});
ro.observe(canvas);
return () => ro.disconnect();
}, []);
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackId) return;
const rect = canvas.getBoundingClientRect();
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
useEffect(() => {
const up = () => { isDragging.current = false; };
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
/>
);
}
+56 -22
View File
@@ -147,17 +147,17 @@ const enTranslation = {
play: 'Play',
trackGenre: 'Genre',
excludeAudiobooks: 'Exclude audiobooks & radio plays',
excludeAudiobooksDesc: 'Filters genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
genreBlocked: 'Genre blocked',
genreAddedToBlacklist: 'Added to blacklist',
excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword blocked',
genreAddedToBlacklist: 'Added to filter list',
genreAlreadyBlocked: 'Already blocked',
blacklistToggle: 'Genre Blacklist',
blacklistToggle: 'Keyword Filter',
genreMixTitle: 'Genre Mix',
genreMixDesc: 'Select a genre to get a curated random mix',
genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.',
filterPanelTitle: 'Filters',
genreClickHint: 'Click a genre tag\nto add it to your blacklist.\nExcluded from future mixes.',
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title & album.',
},
playlists: {
title: 'Playlists',
@@ -277,12 +277,12 @@ const enTranslation = {
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Genre Blacklist',
randomMixBlacklistDesc: 'Songs whose genre matches an entry in this list are excluded from the Random Mix (when "Exclude audiobooks" is active).',
randomMixBlacklistPlaceholder: 'Add genre…',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add keyword…',
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom genres added yet.',
randomMixHardcodedTitle: 'Built-in exclusions (active when checkbox is on)',
randomMixBlacklistEmpty: 'No custom keywords added yet.',
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
},
help: {
title: 'Help',
@@ -299,7 +299,7 @@ const enTranslation = {
q5: 'What keyboard shortcuts are available?',
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
q6: 'What is the queue?',
a6: 'The queue shows all upcoming tracks. Open it with the list icon in the player bar. You can reorder tracks by dragging and save the current queue as a playlist.',
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
q7: 'How do I open the fullscreen player?',
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
q8: 'How does repeat work?',
@@ -325,6 +325,21 @@ const enTranslation = {
a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.',
q17: 'When is a scrobble sent?',
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
q22: 'What is the waveform in the player bar?',
a22: 'The waveform seekbar replaces the classic progress slider. Click anywhere on it to jump to that position, or drag to scrub. The played portion glows with a blue-to-mauve gradient, the buffered range appears slightly brighter, and the unplayed portion is faded.',
q23: 'How do I use the MilkDrop visualizer?',
a23: 'Open the fullscreen player (click the album art in the player bar), then click the waveform icon in the top-right corner. The visualizer starts with a random MilkDrop preset. Use the shuffle button next to it to cycle through hundreds of presets.',
q24: 'Can I shuffle the queue?',
a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.',
q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?',
a25: 'No — they open in a dedicated in-app window so you never leave Psysonic. The window shows the full website and can be closed independently.',
s7: 'Random Mix',
q26: 'What is Random Mix?',
a26: 'Random Mix builds a playlist of random tracks from your entire library. Open it via "Random Mix" in the sidebar and click "New Mix". You can adjust the track count before generating.',
q27: 'What is the Keyword Filter?',
a27: 'The Keyword Filter excludes tracks whose genre, title, or album name contains specific words. Audiobooks are filtered automatically. Add custom keywords in Settings → Random Mix, or click any genre tag in the track list to add it instantly.',
q28: 'What is the Super Genre Mix?',
a28: 'The Super Genre Mix groups your library into broad categories (Rock, Metal, Electronic, Jazz, Classical, etc.) and builds a focused mix from that style. Select a category chip below the track list. Results appear progressively as each sub-genre is fetched.',
s6: 'Troubleshooting',
q18: 'Cover art and artist images load slowly.',
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
@@ -366,6 +381,8 @@ const enTranslation = {
openFullscreen: 'Open Fullscreen Player',
fullscreen: 'Fullscreen Player',
closeFullscreen: 'Close Fullscreen',
visualizer: 'Visualizer',
nextPreset: 'Next preset',
closeTooltip: 'Close (Esc)',
noTitle: 'No Title',
stop: 'Stop',
@@ -529,17 +546,17 @@ const deTranslation = {
play: 'Abspielen',
trackGenre: 'Genre',
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
excludeAudiobooksDesc: 'Filtert Genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
genreBlocked: 'Genre gesperrt',
genreAddedToBlacklist: 'Zur Blacklist hinzugefügt',
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword gesperrt',
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
genreAlreadyBlocked: 'Bereits gesperrt',
blacklistToggle: 'Genre-Blacklist',
blacklistToggle: 'Keyword-Filter',
genreMixTitle: 'Genre-Mix',
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
filterPanelTitle: 'Filter',
genreClickHint: 'Genre-Tag anklicken,\num es zur Blacklist hinzuzufügen.\nBeim nächsten Mix ausgeschlossen.',
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel & Album.',
},
playlists: {
title: 'Playlists',
@@ -659,12 +676,12 @@ const deTranslation = {
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Genre-Blacklist',
randomMixBlacklistDesc: 'Songs, deren Genre einem Eintrag in dieser Liste entspricht, werden aus dem Zufallsmix ausgeschlossen (wenn „Hörbücher ausschließen" aktiv ist).',
randomMixBlacklistPlaceholder: 'Genre hinzufügen…',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Genres hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Ausschlüsse (aktiv wenn Checkbox an)',
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
},
help: {
title: 'Hilfe',
@@ -681,7 +698,7 @@ const deTranslation = {
q5: 'Welche Tastenkürzel gibt es?',
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
q6: 'Was ist die Warteschlange?',
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Listen-Icon in der Playerleiste. Tracks können per Drag & Drop umsortiert und als Playlist gespeichert werden.',
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
q7: 'Wie öffne ich den Vollbild-Player?',
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
q8: 'Wie funktioniert die Wiederholfunktion?',
@@ -707,6 +724,21 @@ const deTranslation = {
a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.',
q17: 'Wann wird ein Scrobble gesendet?',
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
q22: 'Was ist die Waveform in der Playerleiste?',
a22: 'Die Waveform-Leiste ersetzt den klassischen Fortschrittsbalken. Klick auf eine beliebige Stelle zum Springen, ziehen zum Scrubben. Der gespielte Teil leuchtet in einem Blau-Mauve-Farbverlauf, der gepufferte Bereich erscheint etwas heller, der ungespielter Teil ist abgeblendet.',
q23: 'Wie benutze ich den MilkDrop-Visualizer?',
a23: 'Vollbild-Player öffnen (Klick auf das Album-Cover in der Playerleiste), dann oben rechts auf das Waveform-Icon klicken. Der Visualizer startet mit einem zufälligen MilkDrop-Preset. Mit dem Shuffle-Button daneben durch Hunderte von Presets wechseln.',
q24: 'Kann ich die Warteschlange mischen?',
a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.',
q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?',
a25: 'Nein — sie öffnen sich in einem eigenen In-App-Fenster, sodass du Psysonic nie verlassen musst. Das Fenster zeigt die vollständige Website und kann unabhängig geschlossen werden.',
s7: 'Zufallsmix',
q26: 'Was ist der Zufallsmix?',
a26: 'Der Zufallsmix erstellt eine Playlist aus zufälligen Tracks deiner gesamten Bibliothek. Über "Zufallsmix" in der Seitenleiste erreichbar, dann "Neuer Mix" klicken. Die Anzahl der Tracks ist vor der Generierung einstellbar.',
q27: 'Was ist der Keyword-Filter?',
a27: 'Der Keyword-Filter schließt Tracks aus, deren Genre, Titel oder Album bestimmte Wörter enthält. Hörbücher werden automatisch gefiltert. Eigene Keywords in Einstellungen → Zufallsmix hinzufügen oder per Klick auf ein Genre-Tag direkt in der Trackliste.',
q28: 'Was ist der Super-Genre-Mix?',
a28: 'Der Super-Genre-Mix fasst die Bibliothek in übergeordnete Kategorien zusammen (Rock, Metal, Electronic, Jazz, Klassik usw.) und erstellt daraus einen fokussierten Mix. Einen Kategorie-Chip unterhalb der Trackliste auswählen. Ergebnisse erscheinen schrittweise, während die einzelnen Sub-Genres geladen werden.',
s6: 'Problemlösung',
q18: 'Cover und Künstlerbilder laden langsam.',
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
@@ -748,6 +780,8 @@ const deTranslation = {
openFullscreen: 'Vollbild-Player öffnen',
fullscreen: 'Vollbild-Player',
closeFullscreen: 'Vollbild schließen',
visualizer: 'Visualizer',
nextPreset: 'Nächstes Preset',
closeTooltip: 'Schließen (Esc)',
noTitle: 'Kein Titel',
stop: 'Stop',
+10 -7
View File
@@ -101,6 +101,7 @@ export default function AlbumDetail() {
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [ratings, setRatings] = useState<Record<string, number>>({});
@@ -379,8 +380,8 @@ export default function AlbumDetail() {
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
@@ -435,11 +436,13 @@ export default function AlbumDetail() {
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => handlePlaySong(song)}
>
{hoveredSongId === song.id
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar"/><span className="eq-bar"/><span className="eq-bar"/></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
@@ -463,7 +466,7 @@ export default function AlbumDetail() {
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)}
/>
<div className="track-duration" style={{ textAlign: 'right' }}>
<div className="track-duration" style={{ textAlign: 'center' }}>
{formatDuration(song.duration)}
</div>
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
+13 -4
View File
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -77,7 +77,16 @@ export default function ArtistDetail() {
});
}, [id]);
const openLink = (url: string) => open(url);
const openLink = (url: string, title: string) => {
const label = `browser_${Date.now()}`;
new WebviewWindow(label, {
url,
title,
width: 1100,
height: 780,
center: true,
});
};
const toggleStar = async () => {
if (!artist) return;
@@ -183,12 +192,12 @@ export default function ArtistDetail() {
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
{info?.lastFmUrl && (
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
<LastfmIcon size={14} />
Last.fm
</button>
)}
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
<ExternalLink size={14} />
Wikipedia
</button>
+14 -1
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; }
@@ -42,6 +42,9 @@ export default function Help() {
{ q: t('help.q6'), a: t('help.a6') },
{ q: t('help.q7'), a: t('help.a7') },
{ q: t('help.q8'), a: t('help.a8') },
{ q: t('help.q22'), a: t('help.a22') },
{ q: t('help.q23'), a: t('help.a23') },
{ q: t('help.q24'), a: t('help.a24') },
],
},
{
@@ -51,6 +54,7 @@ export default function Help() {
{ q: t('help.q9'), a: t('help.a9') },
{ q: t('help.q10'), a: t('help.a10') },
{ q: t('help.q11'), a: t('help.a11') },
{ q: t('help.q25'), a: t('help.a25') },
],
},
{
@@ -71,6 +75,15 @@ export default function Help() {
{ q: t('help.q17'), a: t('help.a17') },
],
},
{
icon: <Shuffle size={18} />,
title: t('help.s7'),
items: [
{ q: t('help.q26'), a: t('help.a26') },
{ q: t('help.q27'), a: t('help.a27') },
{ q: t('help.q28'), a: t('help.a28') },
],
},
{
icon: <Wrench size={18} />,
title: t('help.s6'),
+5 -3
View File
@@ -8,6 +8,7 @@ export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -15,12 +16,13 @@ export default function Home() {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
getAlbumList('frequent', 12).catch(() => []),
]).then(([s, n, r, f]) => {
setStarred(s);
setRecent(n);
setRandom(r);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
setLoading(false);
}).catch(() => setLoading(false));
@@ -47,7 +49,7 @@ export default function Home() {
return (
<div className="animate-fade-in">
<Hero />
<Hero albums={heroAlbums} />
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? (
+1 -28
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Plus } from 'lucide-react';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const AUDIOBOOK_GENRES = [
@@ -42,7 +42,6 @@ export default function RandomMix() {
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
@@ -56,7 +55,6 @@ export default function RandomMix() {
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixMatchedGenres, setGenreMixMatchedGenres] = useState<string[]>([]);
const fetchSongs = () => {
setLoading(true);
@@ -124,7 +122,6 @@ export default function RandomMix() {
const matched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value);
setGenreMixMatchedGenres(matched);
setGenreMixLoading(true);
setGenreMixSongs([]);
@@ -154,27 +151,6 @@ export default function RandomMix() {
setGenreMixLoading(false);
};
const loadMoreGenreMix = async () => {
if (!genreMixMatchedGenres.length) return;
setGenreMixLoading(true);
try {
const perGenre = Math.max(1, Math.ceil(10 / genreMixMatchedGenres.length));
const settled = await Promise.allSettled(genreMixMatchedGenres.map(g => getRandomSongs(perGenre, g, 45000)));
const combined = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
enqueue(combined.slice(0, 10).map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})));
} finally {
setGenreMixLoading(false);
}
};
return (
<div className="content-body animate-fade-in">
@@ -320,9 +296,6 @@ export default function RandomMix() {
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }} onClick={loadMoreGenreMix} disabled={genreMixLoading}>
<Plus size={14} /> {t('randomMix.genreMixLoadMore')}
</button>
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
</button>
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { version as appVersion } from '../../package.json';
import { useNavigate } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
@@ -465,7 +466,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.12
{t('settings.aboutVersion')} {appVersion}
</div>
</div>
</div>
+47 -15
View File
@@ -570,7 +570,7 @@
.tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
@@ -583,33 +583,33 @@
margin-bottom: var(--space-2);
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
}
.track-row {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
align-items: start;
align-items: center;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
}
.tracklist-total {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
border-top: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
}
.tracklist-total-label {
grid-column: 1 / 5;
@@ -622,7 +622,7 @@
}
.tracklist-total-value {
grid-column: 5 / 6;
text-align: right;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
@@ -631,14 +631,46 @@
.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; }
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
.track-row:hover { background: var(--bg-hover); }
.track-row.active { background: var(--accent-dim); animation: track-pulse 2.5s ease-in-out infinite; }
.track-row.active { background: var(--accent-dim); animation: track-pulse 3s ease-in-out infinite; }
.track-row.active:hover { background: var(--accent-dim); animation: none; }
@keyframes track-pulse {
0%, 100% { background: var(--accent-dim); }
50% { background: transparent; }
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
.track-num {
display: flex;
align-items: center;
justify-content: center;
}
/* Equalizer bars — shown for the currently playing track */
.eq-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
width: 14px;
height: 13px;
}
.eq-bar {
flex: 1;
height: 100%;
background: var(--accent);
border-radius: 1px;
transform-origin: bottom;
transform: scaleY(0.25);
animation: eq-bounce 1.1s ease-in-out infinite;
}
.eq-bar:nth-child(1) { animation-delay: 0s; animation-duration: 1.0s; }
.eq-bar:nth-child(2) { animation-delay: 0.18s; animation-duration: 1.3s; }
.eq-bar:nth-child(3) { animation-delay: 0.35s; animation-duration: 0.9s; }
@keyframes eq-bounce {
0%, 100% { transform: scaleY(0.25); }
50% { transform: scaleY(1); }
}
/* CD / Disc separator */
.disc-header {
display: flex;
@@ -657,7 +689,7 @@
.disc-header:first-child { border-top: none; margin-top: 0; }
.disc-icon { font-size: 16px; }
.track-num { font-size: 13px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; }
.track-num { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.track-info { min-width: 0; }
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; }
@@ -799,12 +831,12 @@
.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
.help-item { border-bottom: 1px solid var(--border-subtle); }
.help-item:last-child { border-bottom: none; }
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); transition: background var(--transition-fast); }
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); border-left: 3px solid transparent; transition: background var(--transition-fast), border-color var(--transition-fast); }
.help-question:hover { background: var(--bg-hover); }
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); }
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); border-left: 3px solid var(--accent); padding-left: calc(var(--space-5) + 3px); }
.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; }
.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); }
.help-answer { padding: var(--space-3) var(--space-5) var(--space-5); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-hover); border-top: 1px solid var(--border-subtle); }
.help-answer { padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-app); border-top: 1px solid var(--border-subtle); border-left: 3px solid var(--accent); }
.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.settings-about { display: flex; flex-direction: column; }
.settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
+33 -66
View File
@@ -270,11 +270,10 @@
/* ─── Player Bar ─── */
.player-bar {
grid-area: player;
display: grid;
grid-template-columns: 280px 1fr 280px;
display: flex;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-6);
padding: 0 var(--space-5);
background: var(--bg-player);
border-top: 1px solid var(--border-subtle);
height: var(--player-height);
@@ -286,6 +285,8 @@
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
width: 220px;
min-width: 0;
}
@@ -366,18 +367,11 @@
margin-top: 2px;
}
.player-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 12px 0;
}
.player-buttons {
display: flex;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
flex-shrink: 0;
}
.player-btn {
@@ -389,6 +383,7 @@
border-radius: 50%;
color: var(--text-muted);
transition: all var(--transition-fast);
flex-shrink: 0;
}
.player-btn:hover {
@@ -397,9 +392,14 @@
transform: scale(1.12);
}
.player-btn-sm {
width: 28px;
height: 28px;
}
.player-btn-primary {
width: 48px;
height: 48px;
width: 46px;
height: 46px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
border-radius: 50%;
@@ -410,16 +410,22 @@
.player-btn-primary:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.07);
transform: scale(1.06);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
}
.player-progress {
/* Waveform seekbar section */
.player-waveform-section {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
max-width: 480px;
gap: var(--space-2);
}
.player-waveform-wrap {
flex: 1;
min-width: 0;
}
.player-time {
@@ -427,62 +433,23 @@
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.player-progress-bar {
flex: 1;
position: relative;
}
.player-progress-bar input[type="range"] {
width: 100%;
}
.player-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
}
.volume-control {
/* Volume section */
.player-volume-section {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 120px;
flex-shrink: 0;
width: 155px;
}
.volume-control svg { flex-shrink: 0; color: var(--text-muted); }
.volume-control input[type="range"] { flex: 1; }
/* Connection Toggle */
.conn-toggle {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 2px;
font-size: 11px;
font-weight: 600;
overflow: hidden;
user-select: none;
.player-volume-slider {
flex: 1;
min-width: 0;
}
.conn-toggle-btn {
padding: 4px 10px;
border-radius: var(--radius-full);
transition: all var(--transition-fast);
color: var(--text-muted);
cursor: pointer;
letter-spacing: 0.03em;
}
.conn-toggle-btn.active {
background: var(--accent);
color: var(--ctp-crust);
}
/* ─── Queue Panel ─── */
.queue-panel {
+2
View File
@@ -662,6 +662,8 @@ select.input.input:focus {
display: flex;
gap: 2px;
color: var(--ctp-overlay1);
justify-content: center;
width: 100%;
}
.star-rating .star.filled { color: var(--ctp-yellow); }
.star-rating .star:hover { color: var(--ctp-yellow); cursor: pointer; }
+20
View File
@@ -0,0 +1,20 @@
declare module 'butterchurn' {
interface Visualizer {
connectAudio(audioNode: AudioNode): void;
loadPreset(preset: unknown, blendTime: number): void;
render(): void;
setRendererSize(width: number, height: number): void;
}
interface CreateOptions {
width: number;
height: number;
pixelRatio?: number;
}
function createVisualizer(ctx: AudioContext, canvas: HTMLCanvasElement, opts: CreateOptions): Visualizer;
export default { createVisualizer };
}
declare module 'butterchurn-presets' {
function getPresets(): Record<string, unknown>;
export default { getPresets };
}