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
+44
View File
@@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2026-03-15
### Added
#### Player Bar — Complete Redesign
- **Waveform seekbar**: Replaces the classic thin slider. A canvas-based waveform with 500 deterministic bars (seeded by `trackId`) fills the full available width. Played portion renders as a blue → mauve gradient with a soft glow; buffered range is slightly brighter; unplayed bars are dimmed to 28% opacity. Click or drag anywhere to seek.
- **New layout**: Single flex row — `[Cover + Track Info] [Transport Controls] [Waveform + Times] [Volume]`. More breathing room for the waveform; controls feel lighter and better proportioned.
- **Queue toggle relocated**: Moved from the bottom player bar to the top-right of the content header — consistent with the sidebar collapse button pattern. Uses `PanelRightClose` / `PanelRight` icons (same family as `PanelLeftClose` / `PanelLeft` in the sidebar).
#### Ambient Stage — MilkDrop Visualizer
- **Butterchurn integration**: Clicking the waveform icon (top-right of the fullscreen player) activates the MilkDrop visualizer powered by [butterchurn](https://github.com/jberg/butterchurn) + `butterchurn-presets`.
- A hidden `<audio>` element is routed through the Web Audio API `AnalyserNode` (not connected to `AudioDestinationNode` — completely silent). The Rust/rodio engine continues to handle actual audio output.
- Starts with a random preset; the shuffle button cycles through all available presets with a 2-second blend transition. Current preset name is shown in the top bar.
- When the visualizer is active, the blurred background, orbs, and overlay are replaced by the canvas.
#### Tracklist — Animated Equalizer Indicator
- The currently **playing** track shows three animated equalizer bars (CSS `scaleY` keyframe animation, staggered timing) instead of a static play icon.
- When **paused**, the static play icon is shown.
- Hovering any other track still shows a play icon.
- Track row alignment fixed: `align-items: center` on the grid row + `.track-num` as flex center — icons and track numbers are now perfectly vertically aligned with the song title.
#### Artist Pages — In-App Browser
- Last.fm and Wikipedia buttons now open a native **Tauri `WebviewWindow`** (1100 × 780, centered) instead of the system browser. Both sites load fully within the app and can be closed independently.
- Required new capabilities: `core:window:allow-create`, `core:webview:allow-create-webview-window`.
#### Update Checker
- Update check now runs **every 10 minutes** during runtime in addition to the initial check 1.5 s after launch.
- Version label in the update toast no longer includes a `v` prefix (shows `1.3.0` instead of `v1.3.0`).
#### Help Page
- New **Random Mix** section: explains the random mix, keyword filter, and super genre mix.
- Updated **Playback** section: waveform seekbar, MilkDrop visualizer, queue shuffle.
- Updated **Library** section: in-app browser for artist links.
- Updated queue entry to reflect the new toggle location.
- **Accordion styling**: open question and answer share a continuous 3 px accent stripe on the left; answer background uses `--bg-app` for clear contrast against the question's `--bg-card`.
### Fixed
- **Version in Settings** was hardcoded to `1.0.12`. Now imported from `package.json` at build time — same source as the sidebar update checker.
- **Hero / Discover duplicate albums**: Both sections previously fetched `random` independently, often showing the same albums. Now a single request fetches 20; `slice(0, 8)` goes to the Hero carousel and `slice(8)` to the Discover row.
- **Active track pulse too aggressive**: Changed from a `background: transparent` flash to a gentle `opacity: 0.6` fade over 3 s — significantly less distracting.
### Changed
- **Blacklist → Keyword Filter**: Renamed throughout UI and i18n (EN + DE) to better reflect that the filter matches genre, title, and album fields — not just genre tags.
## [1.2.0] - 2026-03-15 ## [1.2.0] - 2026-03-15
### Added ### Added
+7 -5
View File
@@ -22,21 +22,23 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features ## ✨ Features
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations. - 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps. -**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German. - 🌍 **Internationalization (i18n)**: Fully translated into English and German.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time. - 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server. - 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images. - 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine. - 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing. - 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 🎛 **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported. - **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
- 🔄 **Update Notifications**: Built-in update checker that notifies you when a new version is available on GitHub. - 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support). - 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
## ● Known Limitations ## ● Known Limitations
- **Seeking (all platforms)**: Seeking may occasionally be unreliable — especially on Linux/GStreamer. This is a known issue currently being worked on.
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop is being actively worked on and may not always behave correctly on macOS and Windows.
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly. - **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
## 📥 Installation ## 📥 Installation
+61 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.0.12", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "psysonic", "name": "psysonic",
"version": "1.0.12", "version": "1.3.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
@@ -16,6 +16,8 @@
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7", "axios": "^1.7.7",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16", "i18next": "^25.8.16",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"md5": "^2.3.0", "md5": "^2.3.0",
@@ -1655,6 +1657,16 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1702,6 +1714,27 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/butterchurn": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"ecma-proposal-math-extensions": "0.0.2"
}
},
"node_modules/butterchurn-presets": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.26.0",
"ecma-proposal-math-extensions": "0.0.2",
"lodash": "^4.17.4"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1764,6 +1797,14 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/crypt": { "node_modules/crypt": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
@@ -1821,6 +1862,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecma-proposal-math-extensions": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.307", "version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
@@ -2179,6 +2226,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2433,6 +2486,12 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,6 +19,8 @@
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7", "axios": "^1.7.7",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16", "i18next": "^25.8.16",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"md5": "^2.3.0", "md5": "^2.3.0",
+1 -1
View File
@@ -3118,7 +3118,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.2.0" version = "1.3.0"
dependencies = [ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"rodio", "rodio",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.2.0" version = "1.3.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+3 -1
View File
@@ -27,6 +27,8 @@
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-fullscreen", "core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen" "core:window:allow-is-fullscreen",
"core:window:allow-create",
"core:webview:allow-create-webview-window"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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"],"platforms":["linux","macOS","windows"]}} {"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","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"],"platforms":["linux","macOS","windows"]}}
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.2.0", "version": "1.3.0",
"identifier": "dev.psysonic.app", "identifier": "dev.psysonic.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -22,7 +22,8 @@
"fullscreen": false, "fullscreen": false,
"decorations": true, "decorations": true,
"transparent": false, "transparent": false,
"visible": true "visible": true,
"dragDropEnabled": false
} }
], ],
"security": { "security": {
+11
View File
@@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar'; import PlayerBar from './components/PlayerBar';
import LiveSearch from './components/LiveSearch'; import LiveSearch from './components/LiveSearch';
@@ -37,9 +39,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
} }
function AppShell() { function AppShell() {
const { t } = useTranslation();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue); const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -124,6 +128,13 @@ function AppShell() {
<LiveSearch /> <LiveSearch />
<div className="spacer" /> <div className="spacer" />
<NowPlayingDropdown /> <NowPlayingDropdown />
<button
className="collapse-btn"
onClick={toggleQueue}
title={t('player.toggleQueue')}
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
</button>
</header> </header>
<div className="content-body" style={{ padding: 0 }}> <div className="content-body" style={{ padding: 0 }}>
<Routes> <Routes>
+53 -9
View File
@@ -1,12 +1,13 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import { import {
Play, Pause, SkipBack, SkipForward, Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage'; import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import VisualizerCanvas from './VisualizerCanvas';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -147,6 +148,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop); const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat); 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 duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -177,20 +182,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return ( return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}> <div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1 — blurred artist image (falls back to cover art) */} {/* Layer 1 — blurred artist image OR visualizer */}
<FsBg url={bgUrl} /> {vizActive && currentTrack ? (
<div className="fs-bg-overlay" aria-hidden="true" /> <VisualizerCanvas
trackId={currentTrack.id}
{/* Layer 2 — drifting color orbs */} nextPresetTrigger={nextPresetTrigger}
<div className="fs-orb fs-orb-1" aria-hidden="true" /> onPresetName={setPresetName}
<div className="fs-orb fs-orb-2" aria-hidden="true" /> />
<div className="fs-orb fs-orb-3" aria-hidden="true" /> ) : (
<>
<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 */} {/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}> <button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} /> <ChevronDown size={28} />
</button> </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 */} {/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage"> <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 { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -50,8 +54,9 @@ export default function Hero() {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {}); getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, []); }, [albumsProp]);
// Start / restart auto-advance timer // Start / restart auto-advance timer
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
+68 -90
View File
@@ -1,10 +1,12 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { 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'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -18,32 +20,27 @@ function formatTime(seconds: number): string {
export default function PlayerBar() { export default function PlayerBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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 duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : ''; 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>) => { const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value)); setVolume(parseFloat(e.target.value));
}, [setVolume]); }, [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 = { const volumeStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
}; };
return ( return (
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}> <footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
{/* Track Info */} {/* Track Info */}
<div className="player-track-info"> <div className="player-track-info">
<div <div
@@ -89,88 +86,69 @@ export default function PlayerBar() {
</div> </div>
</div> </div>
{/* Controls + Progress */} {/* Transport Controls */}
<div className="player-controls"> <div className="player-buttons">
<div className="player-buttons"> <button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}> <Square size={14} fill="currentColor" />
<Square size={16} fill="currentColor" /> </button>
</button> <button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}> </button>
<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 <button
className="player-btn" className="player-btn player-btn-primary"
onClick={toggleQueue} onClick={togglePlay}
aria-label={t('player.toggleQueue')} aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={t('player.toggleQueue')} data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
> >
<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> </button>
</div> </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> </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() { export default function QueuePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -154,10 +158,9 @@ export default function QueuePanel() {
const onDragStart = (e: React.DragEvent, index: number) => { const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true; isDraggingInternalRef.current = true;
draggedIdxRef.current = index; draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index); setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move'; 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 })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
}; };
@@ -179,6 +182,9 @@ export default function QueuePanel() {
isDraggingInternalRef.current = false; isDraggingInternalRef.current = false;
draggedIdxRef.current = null; draggedIdxRef.current = null;
dragOverIdxRef.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) => { const onDropQueue = async (e: React.DragEvent) => {
@@ -197,9 +203,11 @@ export default function QueuePanel() {
if (raw) parsedData = JSON.parse(raw); if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ } } catch { /* ignore */ }
if (parsedData?.type === 'queue_reorder') { if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
// fromIdx: always reliable from dataTransfer (set during dragstart) // fromIdx: prefer dataTransfer value; fall back to module-level var for
const fromIdx: number = parsedData.index; // 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. // toIdx: calculate from drop coordinates — avoids all ref timing issues.
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2). // 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) // External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return; if (!parsedData) return;
if (parsedData.type === 'song') { if (parsedData.type === 'song') {
enqueue([parsedData.track]); enqueue([parsedData.track]);
+9 -4
View File
@@ -72,20 +72,25 @@ export default function Sidebar({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const timer = setTimeout(async () => {
const check = async () => {
try { try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest'); const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const tag: string = data.tag_name ?? ''; const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, appVersion)) { if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`); setLatestVersion(tag.replace(/^v/i, ''));
} }
} catch { } catch {
// network unavailable — silently skip // 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 ( 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', play: 'Play',
trackGenre: 'Genre', trackGenre: 'Genre',
excludeAudiobooks: 'Exclude audiobooks & radio plays', excludeAudiobooks: 'Exclude audiobooks & radio plays',
excludeAudiobooksDesc: 'Filters genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …', excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Genre blocked', genreBlocked: 'Keyword blocked',
genreAddedToBlacklist: 'Added to blacklist', genreAddedToBlacklist: 'Added to filter list',
genreAlreadyBlocked: 'Already blocked', genreAlreadyBlocked: 'Already blocked',
blacklistToggle: 'Genre Blacklist', blacklistToggle: 'Keyword Filter',
genreMixTitle: 'Genre Mix', genreMixTitle: 'Genre Mix',
genreMixDesc: 'Select a genre to get a curated random mix', genreMixDesc: 'Select a genre to get a curated random mix',
genreMixLoadMore: 'Load 10 more', genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.', genreMixNoGenres: 'No genres found on server.',
filterPanelTitle: 'Filters', 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: { playlists: {
title: 'Playlists', title: 'Playlists',
@@ -277,12 +277,12 @@ const enTranslation = {
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic', aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
randomMixTitle: 'Random Mix', randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Genre Blacklist', randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs whose genre matches an entry in this list are excluded from the Random Mix (when "Exclude audiobooks" is active).', randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add genre…', randomMixBlacklistPlaceholder: 'Add keyword…',
randomMixBlacklistAdd: 'Add', randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom genres added yet.', randomMixBlacklistEmpty: 'No custom keywords added yet.',
randomMixHardcodedTitle: 'Built-in exclusions (active when checkbox is on)', randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
}, },
help: { help: {
title: 'Help', title: 'Help',
@@ -299,7 +299,7 @@ const enTranslation = {
q5: 'What keyboard shortcuts are available?', 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.', 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?', 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?', 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.', 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?', 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.', 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?', q17: 'When is a scrobble sent?',
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.', 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', s6: 'Troubleshooting',
q18: 'Cover art and artist images load slowly.', 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.', 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', openFullscreen: 'Open Fullscreen Player',
fullscreen: 'Fullscreen Player', fullscreen: 'Fullscreen Player',
closeFullscreen: 'Close Fullscreen', closeFullscreen: 'Close Fullscreen',
visualizer: 'Visualizer',
nextPreset: 'Next preset',
closeTooltip: 'Close (Esc)', closeTooltip: 'Close (Esc)',
noTitle: 'No Title', noTitle: 'No Title',
stop: 'Stop', stop: 'Stop',
@@ -529,17 +546,17 @@ const deTranslation = {
play: 'Abspielen', play: 'Abspielen',
trackGenre: 'Genre', trackGenre: 'Genre',
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen', excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
excludeAudiobooksDesc: 'Filtert Genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …', excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Genre gesperrt', genreBlocked: 'Keyword gesperrt',
genreAddedToBlacklist: 'Zur Blacklist hinzugefügt', genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
genreAlreadyBlocked: 'Bereits gesperrt', genreAlreadyBlocked: 'Bereits gesperrt',
blacklistToggle: 'Genre-Blacklist', blacklistToggle: 'Keyword-Filter',
genreMixTitle: 'Genre-Mix', genreMixTitle: 'Genre-Mix',
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix', genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
genreMixLoadMore: '10 weitere laden', genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.', genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
filterPanelTitle: 'Filter', 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: { playlists: {
title: 'Playlists', title: 'Playlists',
@@ -659,12 +676,12 @@ const deTranslation = {
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio', aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic', aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
randomMixTitle: 'Zufallsmix', randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Genre-Blacklist', randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs, deren Genre einem Eintrag in dieser Liste entspricht, werden aus dem Zufallsmix ausgeschlossen (wenn „Hörbücher ausschließen" aktiv ist).', randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistPlaceholder: 'Genre hinzufügen…', randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
randomMixBlacklistAdd: 'Hinzufügen', randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Genres hinzugefügt.', randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Ausschlüsse (aktiv wenn Checkbox an)', randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
}, },
help: { help: {
title: 'Hilfe', title: 'Hilfe',
@@ -681,7 +698,7 @@ const deTranslation = {
q5: 'Welche Tastenkürzel gibt es?', 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.', 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?', 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?', 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.', 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?', 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.', 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?', q17: 'Wann wird ein Scrobble gesendet?',
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.', 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', s6: 'Problemlösung',
q18: 'Cover und Künstlerbilder laden langsam.', 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.', 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', openFullscreen: 'Vollbild-Player öffnen',
fullscreen: 'Vollbild-Player', fullscreen: 'Vollbild-Player',
closeFullscreen: 'Vollbild schließen', closeFullscreen: 'Vollbild schließen',
visualizer: 'Visualizer',
nextPreset: 'Nächstes Preset',
closeTooltip: 'Schließen (Esc)', closeTooltip: 'Schließen (Esc)',
noTitle: 'Kein Titel', noTitle: 'Kein Titel',
stop: 'Stop', stop: 'Stop',
+10 -7
View File
@@ -101,6 +101,7 @@ export default function AlbumDetail() {
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null); const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]); const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [ratings, setRatings] = useState<Record<string, number>>({}); const [ratings, setRatings] = useState<Record<string, number>>({});
@@ -379,8 +380,8 @@ export default function AlbumDetail() {
<div>{t('albumDetail.trackTitle')}</div> <div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>} {hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div> <div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div> <div style={{ textAlign: 'center' }}>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div> <div style={{ textAlign: 'center' }}>{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div> <div>{t('albumDetail.trackFormat')}</div>
</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 }} style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => handlePlaySong(song)} onClick={() => handlePlaySong(song)}
> >
{hoveredSongId === song.id {hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" /> ? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id : currentTrack?.id === song.id && isPlaying
? <Play size={13} fill="currentColor" /> ? <div className="eq-bars"><span className="eq-bar"/><span className="eq-bar"/><span className="eq-bar"/></div>
: (song.track ?? i + 1)} : currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div> </div>
<div className="track-info"> <div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span> <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} value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)} onChange={r => handleRate(song.id, r)}
/> />
<div className="track-duration" style={{ textAlign: 'right' }}> <div className="track-duration" style={{ textAlign: 'center' }}>
{formatDuration(song.duration)} {formatDuration(song.duration)}
</div> </div>
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}> <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 AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; 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 { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -77,7 +77,16 @@ export default function ArtistDetail() {
}); });
}, [id]); }, [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 () => { const toggleStar = async () => {
if (!artist) return; if (!artist) return;
@@ -183,12 +192,12 @@ export default function ArtistDetail() {
{(info?.lastFmUrl || artist.name) && ( {(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links"> <div className="artist-detail-links">
{info?.lastFmUrl && ( {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} /> <LastfmIcon size={14} />
Last.fm Last.fm
</button> </button>
)} )}
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}> <button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
<ExternalLink size={14} /> <ExternalLink size={14} />
Wikipedia Wikipedia
</button> </button>
+14 -1
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; 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'; import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; } 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.q6'), a: t('help.a6') },
{ q: t('help.q7'), a: t('help.a7') }, { q: t('help.q7'), a: t('help.a7') },
{ q: t('help.q8'), a: t('help.a8') }, { 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.q9'), a: t('help.a9') },
{ q: t('help.q10'), a: t('help.a10') }, { q: t('help.q10'), a: t('help.a10') },
{ q: t('help.q11'), a: t('help.a11') }, { 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') }, { 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} />, icon: <Wrench size={18} />,
title: t('help.s6'), title: t('help.s6'),
+5 -3
View File
@@ -8,6 +8,7 @@ export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]); const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]); const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [random, setRandom] = useState<SubsonicAlbum[]>([]); const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]); const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -15,12 +16,13 @@ export default function Home() {
Promise.all([ Promise.all([
getAlbumList('starred', 12).catch(() => []), getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 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(() => []), getAlbumList('frequent', 12).catch(() => []),
]).then(([s, n, r, f]) => { ]).then(([s, n, r, f]) => {
setStarred(s); setStarred(s);
setRecent(n); setRecent(n);
setRandom(r); setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f); setMostPlayed(f);
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
@@ -47,7 +49,7 @@ export default function Home() {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<Hero /> <Hero albums={heroAlbums} />
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}> <div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? ( {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 { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; 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'; import { useTranslation } from 'react-i18next';
const AUDIOBOOK_GENRES = [ const AUDIOBOOK_GENRES = [
@@ -42,7 +42,6 @@ export default function RandomMix() {
const [songs, setSongs] = useState<SubsonicSong[]>([]); const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set()); const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore(); const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null); const [addedGenre, setAddedGenre] = useState<string | null>(null);
@@ -56,7 +55,6 @@ export default function RandomMix() {
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null); const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]); const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false); const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixMatchedGenres, setGenreMixMatchedGenres] = useState<string[]>([]);
const fetchSongs = () => { const fetchSongs = () => {
setLoading(true); setLoading(true);
@@ -124,7 +122,6 @@ export default function RandomMix() {
const matched = serverGenres const matched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))) .filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value); .map(sg2 => sg2.value);
setGenreMixMatchedGenres(matched);
setGenreMixLoading(true); setGenreMixLoading(true);
setGenreMixSongs([]); setGenreMixSongs([]);
@@ -154,27 +151,6 @@ export default function RandomMix() {
setGenreMixLoading(false); 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 ( return (
<div className="content-body animate-fade-in"> <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 }} />} {genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span> </span>
<div style={{ display: 'flex', gap: '0.5rem' }}> <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}> <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')} <Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
</button> </button>
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { version as appVersion } from '../../package.json';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle 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 Psysonic
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.12 {t('settings.aboutVersion')} {appVersion}
</div> </div>
</div> </div>
</div> </div>
+47 -15
View File
@@ -570,7 +570,7 @@
.tracklist { padding: 0 var(--space-6) var(--space-6); } .tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header { .tracklist-header {
display: grid; 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); gap: var(--space-3);
align-items: center; align-items: center;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@@ -583,33 +583,33 @@
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.tracklist-header.tracklist-va { .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 { .track-row {
display: grid; 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); gap: var(--space-3);
align-items: start; align-items: center;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.track-row.track-row-va { .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 { .tracklist-total {
display: grid; 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); gap: var(--space-3);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
margin-top: var(--space-1); margin-top: var(--space-1);
} }
.tracklist-total.tracklist-va { .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 { .tracklist-total-label {
grid-column: 1 / 5; grid-column: 1 / 5;
@@ -622,7 +622,7 @@
} }
.tracklist-total-value { .tracklist-total-value {
grid-column: 5 / 6; grid-column: 5 / 6;
text-align: right; text-align: center;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); 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-label { grid-column: 1 / 6; }
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; } .tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
.track-row:hover { background: var(--bg-hover); } .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; } .track-row.active:hover { background: var(--accent-dim); animation: none; }
@keyframes track-pulse { @keyframes track-pulse {
0%, 100% { background: var(--accent-dim); } 0%, 100% { opacity: 1; }
50% { background: transparent; } 50% { opacity: 0.6; }
} }
.track-row > * { padding-top: 6px; padding-bottom: 6px; } .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 */ /* CD / Disc separator */
.disc-header { .disc-header {
display: flex; display: flex;
@@ -657,7 +689,7 @@
.disc-header:first-child { border-top: none; margin-top: 0; } .disc-header:first-child { border-top: none; margin-top: 0; }
.disc-icon { font-size: 16px; } .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-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-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; } .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-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 { border-bottom: 1px solid var(--border-subtle); }
.help-item:last-child { border-bottom: none; } .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-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-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-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-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.settings-about { display: flex; flex-direction: column; } .settings-about { display: flex; flex-direction: column; }
.settings-about-header { display: flex; align-items: center; gap: var(--space-4); } .settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
+33 -66
View File
@@ -270,11 +270,10 @@
/* ─── Player Bar ─── */ /* ─── Player Bar ─── */
.player-bar { .player-bar {
grid-area: player; grid-area: player;
display: grid; display: flex;
grid-template-columns: 280px 1fr 280px;
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);
padding: 0 var(--space-6); padding: 0 var(--space-5);
background: var(--bg-player); background: var(--bg-player);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
height: var(--player-height); height: var(--player-height);
@@ -286,6 +285,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
flex-shrink: 0;
width: 220px;
min-width: 0; min-width: 0;
} }
@@ -366,18 +367,11 @@
margin-top: 2px; margin-top: 2px;
} }
.player-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 12px 0;
}
.player-buttons { .player-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-2);
flex-shrink: 0;
} }
.player-btn { .player-btn {
@@ -389,6 +383,7 @@
border-radius: 50%; border-radius: 50%;
color: var(--text-muted); color: var(--text-muted);
transition: all var(--transition-fast); transition: all var(--transition-fast);
flex-shrink: 0;
} }
.player-btn:hover { .player-btn:hover {
@@ -397,9 +392,14 @@
transform: scale(1.12); transform: scale(1.12);
} }
.player-btn-sm {
width: 28px;
height: 28px;
}
.player-btn-primary { .player-btn-primary {
width: 48px; width: 46px;
height: 48px; height: 46px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust); color: var(--ctp-crust);
border-radius: 50%; border-radius: 50%;
@@ -410,16 +410,22 @@
.player-btn-primary:hover { .player-btn-primary:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust); 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); 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; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-2);
width: 100%; }
max-width: 480px;
.player-waveform-wrap {
flex: 1;
min-width: 0;
} }
.player-time { .player-time {
@@ -427,62 +433,23 @@
color: var(--text-muted); color: var(--text-muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
} }
.player-progress-bar { /* Volume section */
flex: 1; .player-volume-section {
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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-width: 120px; flex-shrink: 0;
width: 155px;
} }
.volume-control svg { flex-shrink: 0; color: var(--text-muted); } .player-volume-slider {
flex: 1;
.volume-control input[type="range"] { flex: 1; } min-width: 0;
/* 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;
} }
.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 ─── */
.queue-panel { .queue-panel {
+2
View File
@@ -662,6 +662,8 @@ select.input.input:focus {
display: flex; display: flex;
gap: 2px; gap: 2px;
color: var(--ctp-overlay1); color: var(--ctp-overlay1);
justify-content: center;
width: 100%;
} }
.star-rating .star.filled { color: var(--ctp-yellow); } .star-rating .star.filled { color: var(--ctp-yellow); }
.star-rating .star:hover { color: var(--ctp-yellow); cursor: pointer; } .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 };
}