diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c008352..ef388256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.25.0] - 2026-04-01 + +### Added + +- **System Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: Functional tray icon with context menu — Play / Pause, Previous Track, Next Track, Show / Hide, and Exit Psysonic. Left-clicking the tray icon toggles window visibility. The tray icon is built via `TrayIconBuilder` in Rust so menu events are properly wired. +- **Minimize to Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: New toggle in Settings → Behavior. When enabled, closing the window hides it to the tray instead of exiting. The close button behaviour is intercepted in Rust (`prevent_close` + `window:close-requested` event) and the JS side decides hide vs. exit based on the user setting. +- **Sidebar Customization** *(requested by [@lighthous3d](https://github.com/lighthous3d))*: New section in Settings → Appearance. All library and system nav items can be shown/hidden via a toggle switch and reordered by dragging the grip handle. Order and visibility are persisted across sessions (`psysonic_sidebar` in localStorage). Fixed items (Now Playing, Settings) are listed as non-configurable below the list. +- **Playlist cover art**: Playlist cards on the Playlists overview page now display the server-generated cover image (Navidrome's `coverArt` field on the playlist object) via the IndexedDB image cache. Falls back to the ListMusic icon when no cover is available. + +### Fixed + +- **Cover image flickering**: `buildCoverArtUrl()` generates a new random auth salt on every call, causing `useCachedUrl` to re-trigger on every render and produce a rapid re-fetch loop. Fixed by wrapping all `buildCoverArtUrl` / `coverArtCacheKey` calls in `useMemo` with the cover ID as dependency in `ArtistCardLocal`, `QueuePanel`, `FullscreenPlayer`, `Hero`, and `PlaylistDetail`. +- **DnD text selection**: Dragging a grip handle in the Sidebar Customizer (and any future `useDragSource` consumer) would select all text on the page during the threshold detection phase. Fixed by calling `e.preventDefault()` in `useDragSource`'s `onMouseDown` handler before the drag threshold is reached. +- **Sidebar Customization DnD on Linux**: The initial implementation used the HTML5 Drag & Drop API, which always shows a forbidden cursor on WebKitGTK and does not fire drop events reliably. Rewritten to use the existing psy-drag mouse-event system (`useDragSource` / `psy-drop` custom event), consistent with the Queue panel. + +--- + ## [1.24.0] - 2026-03-31 ### Added diff --git a/package.json b/package.json index be42e8c3..269b1e82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.24.0", + "version": "1.25.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 320af57e..15ca4bb3 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.24.0 +pkgver=1.25.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6e849d05..a3e3751a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.24.0" +version = "1.25.0" dependencies = [ "biquad", "md5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52c2e79c..8ed67810 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.24.0" +version = "1.25.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c411c00f..e832f841 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,11 @@ mod audio; use std::collections::HashMap; use std::sync::Mutex; -use tauri::{Emitter, Manager}; +use tauri::{ + menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Emitter, Manager, +}; /// Tracks which user-configured shortcuts are currently registered (shortcut_str → action). /// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). @@ -300,6 +304,68 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .setup(|app| { + // ── System tray ─────────────────────────────────────────────── + { + let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?; + let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?; + let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?; + let sep1 = PredefinedMenuItem::separator(app)?; + let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?; + + let menu = MenuBuilder::new(app) + .item(&play_pause) + .item(&previous) + .item(&next) + .item(&sep1) + .item(&show_hide) + .item(&sep2) + .item(&quit) + .build()?; + + TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip("Psysonic") + .on_menu_event(|app, event| match event.id.as_ref() { + "play_pause" => { let _ = app.emit("tray:play-pause", ()); } + "next" => { let _ = app.emit("tray:next", ()); } + "previous" => { let _ = app.emit("tray:previous", ()); } + "show_hide" => { + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } else { + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + "quit" => { std::process::exit(0); } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + // Left-click: toggle window visibility + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event { + let app = tray.app_handle(); + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } else { + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + }) + .build(app)?; + } + // ── MPRIS2 / OS media controls via souvlaki ────────────────── { use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig}; @@ -392,6 +458,15 @@ pub fn run() { Ok(()) }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window.label() == "main" { + // Let JS decide: minimize to tray or exit, based on user setting. + api.prevent_close(); + let _ = window.emit("window:close-requested", ()); + } + } + }) .invoke_handler(tauri::generate_handler![ greet, exit_app, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2cd60131..6e507f73 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.24.0", + "version": "1.25.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index c0066a98..3eecce50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -279,7 +279,7 @@ function AppShell() { ); } -// Media key event handler +// Media key + tray event handler function TauriEventBridge() { const togglePlay = usePlayerStore(s => s.togglePlay); const next = usePlayerStore(s => s.next); @@ -338,9 +338,12 @@ function TauriEventBridge() { const setup = async () => { const handlers: Array<[string, () => void]> = [ - ['media:play-pause', () => togglePlay()], - ['media:next', () => next()], - ['media:prev', () => previous()], + ['media:play-pause', () => togglePlay()], + ['media:next', () => next()], + ['media:prev', () => previous()], + ['tray:play-pause', () => togglePlay()], + ['tray:next', () => next()], + ['tray:previous', () => previous()], ['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }], ]; @@ -372,10 +375,14 @@ function TauriEventBridge() { unlisten.push(u); } - // Close → exit app - const win = getCurrentWindow(); - const u = await win.onCloseRequested(async () => { - await invoke('exit_app'); + // window:close-requested is emitted by Rust (prevent_close + emit). + // JS decides: minimize to tray or exit, based on user setting. + const u = await listen('window:close-requested', async () => { + if (useAuthStore.getState().minimizeToTray) { + await getCurrentWindow().hide(); + } else { + await invoke('exit_app'); + } }); if (cancelled) { u(); return; } unlisten.push(u); diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 806bd359..b719881f 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -96,6 +96,7 @@ export interface SubsonicPlaylist { changed: string; owner?: string; public?: boolean; + coverArt?: string; } export interface SubsonicNowPlaying extends SubsonicSong { diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index f0ea90a7..29a7566d 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { Users } from 'lucide-react'; @@ -11,14 +11,18 @@ interface Props { export default function ArtistCardLocal({ artist }: Props) { const navigate = useNavigate(); const coverId = artist.coverArt || artist.id; + // buildCoverArtUrl generates a new crypto salt on every call — must be + // memoized to prevent a new URL on every parent re-render causing refetch loops. + const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]); + const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]); return (
navigate(`/artist/${artist.id}`)}>
{coverId ? ( { diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index ffcb3f05..03c0e62a 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; +import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Music, MicVocal @@ -154,8 +154,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const toggleQueue = usePlayerStore(s => s.toggleQueue); const duration = currentTrack?.duration ?? 0; - const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + // buildCoverArtUrl generates a new salt on every call — must be memoized + // to prevent useCachedUrl from re-fetching on every progress re-render (100 ms). + const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]); + const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]); // useCachedUrl must be called unconditionally (hook rules) const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 63d52b37..c8b09b44 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, ListPlus } from 'lucide-react'; import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; @@ -92,9 +92,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { }); }, [album?.id]); - // Resolve background URL via cache - const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : ''; - const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : ''; + // buildCoverArtUrl generates a new salt on every call — must be memoized. + const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]); + const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]); const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey); // Keep the last known good URL so HeroBg never receives '' during a cache-miss @@ -102,9 +102,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { const stableBgUrl = useRef(''); if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl; - // Resolve cover thumbnail via cache - const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; - const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : ''; + const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]); + const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]); if (!album) return
; diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 61ac57a9..d1e6ea0e 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; @@ -159,6 +159,10 @@ export default function QueuePanel() { const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); const currentTime = usePlayerStore(s => s.currentTime); + const currentCoverSrc = useMemo( + () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', + [currentTrack?.coverArt] + ); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const playTrack = usePlayerStore(s => s.playTrack); const toggleQueue = usePlayerStore(s => s.toggleQueue); @@ -371,7 +375,7 @@ export default function QueuePanel() {
{currentTrack.coverArt ? ( - + ) : (
)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 898bce68..9c4e18e2 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; +import { useSidebarStore } from '../store/sidebarStore'; import { open } from '@tauri-apps/plugin-shell'; import { version as appVersion } from '../../package.json'; import { NavLink } from 'react-router-dom'; @@ -13,17 +14,21 @@ import { import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; -const navItems = [ - { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' }, - { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' }, - { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' }, - { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' }, - { icon: Users, labelKey: 'sidebar.artists', to: '/artists' }, - { icon: Tags, labelKey: 'sidebar.genres', to: '/genres' }, - { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' }, - { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' }, - { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' }, -]; +// All configurable nav items — order and visibility controlled by sidebarStore. +// Exported so Settings can render the same item metadata. +export const ALL_NAV_ITEMS: Record = { + mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' }, + newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' }, + allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' }, + randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums', section: 'library' }, + artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' }, + genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' }, + randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' }, + favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' }, + playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' }, + statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' }, + help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' }, +}; function isNewer(latest: string, current: string): boolean { const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number); @@ -77,8 +82,17 @@ export default function Sidebar({ const offlineAlbums = useOfflineStore(s => s.albums); const serverId = useAuthStore(s => s.activeServerId ?? ''); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + const sidebarItems = useSidebarStore(s => s.items); const [latestVersion, setLatestVersion] = useState(null); + // Resolve ordered, visible items per section from store config + const visibleLibrary = sidebarItems + .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library') + .map(cfg => ALL_NAV_ITEMS[cfg.id]); + const visibleSystem = sidebarItems + .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system') + .map(cfg => ALL_NAV_ITEMS[cfg.id]); + useEffect(() => { let cancelled = false; @@ -122,7 +136,7 @@ export default function Sidebar({