feat: queue improvements, system browser links (v1.4.5)

- Queue: year added to now-playing meta, cover enlarged to 90px + top-aligned, default width 340px
- Artist pages: Last.fm/Wikipedia open in system browser with button label feedback
- README: AUR badge + install section, Nord theme mention, in-app browser bullet removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-17 21:19:07 +01:00
parent d4e44199e9
commit 18199a1f8a
14 changed files with 51 additions and 32 deletions
+12
View File
@@ -5,6 +5,18 @@ 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.4.5] - 2026-03-17
### Changed
#### Artist Pages — External Links
- Last.fm and Wikipedia buttons now open in the **system browser** instead of an in-app window. The button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 seconds as visual confirmation.
#### Queue Panel
- **Release year** added to the now-playing meta box, shown below the album name (when available).
- **Cover art enlarged** from 72 × 72 px to 90 × 90 px, aligned to the top of the meta block so it lines up with the song title.
- **Default width increased** from 300 px to 340 px.
## [1.4.4] - 2026-03-17 ## [1.4.4] - 2026-03-17
### Added ### Added
+1 -1
View File
@@ -181,4 +181,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches. - **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest. - **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
- **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`. - **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`.
- **Version**: 1.4.4 - **Version**: 1.4.5
+7 -3
View File
@@ -7,12 +7,13 @@
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a> <a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a> <a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
</p> </p>
</div> </div>
--- ---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic. Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library. Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
@@ -33,7 +34,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions. - 🌊 **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. - 🎛️ **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. - 🎼 **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. - 🔄 **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).
@@ -47,7 +47,11 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
- **Windows**: `.exe` or `.msi` - **Windows**: `.exe` or `.msi`
- **macOS**: `.dmg` (Universal or Apple Silicon) - **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux**: `.AppImage` or `.deb` - **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
## 🚀 Getting Started ## 🚀 Getting Started
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.4.4", "version": "1.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev> # Maintainer: stelle <stelle@psychotoxical.dev>
pkgname=psysonic pkgname=psysonic
pkgver=1.4.3 pkgver=1.4.5
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3118,7 +3118,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.4.2" version = "1.4.5"
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.4.4" version = "1.4.5"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+6 -3
View File
@@ -19,6 +19,7 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0); app_handle.exit(0);
} }
pub fn run() { pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine(); let (audio_engine, _audio_thread) = audio::create_engine();
@@ -107,9 +108,11 @@ pub fn run() {
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event { if let tauri::WindowEvent::CloseRequested { .. } = event {
// Emit event so JS can decide: hide to tray or allow close. // Only intercept close for the main window (hide to tray).
// JS handles prevent_close via onCloseRequested() in App.tsx. // Browser popup windows (browser_*) close normally.
let _ = window.emit("window:close-requested", ()); if window.label() == "main" {
let _ = window.emit("window:close-requested", ());
}
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
+1 -1
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.4.4", "version": "1.4.5",
"identifier": "dev.psysonic.app", "identifier": "dev.psysonic.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -73,7 +73,7 @@ function AppShell() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true'; return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
}); });
const [queueWidth, setQueueWidth] = useState(300); const [queueWidth, setQueueWidth] = useState(340);
const [isDraggingQueue, setIsDraggingQueue] = useState(false); const [isDraggingQueue, setIsDraggingQueue] = useState(false);
useEffect(() => { useEffect(() => {
+3 -1
View File
@@ -316,7 +316,9 @@ export default function QueuePanel() {
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }} style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div> >{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
<div className="queue-current-tech"> <div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix ? ( {currentTrack.bitRate && currentTrack.suffix ? (
+2
View File
@@ -120,6 +120,7 @@ const enTranslation = {
favorite: 'Favorite', favorite: 'Favorite',
albumCount_one: '{{count}} Album', albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums', albumCount_other: '{{count}} Albums',
openedInBrowser: 'Opened in browser',
}, },
favorites: { favorites: {
title: 'Favorites', title: 'Favorites',
@@ -539,6 +540,7 @@ const deTranslation = {
favorite: 'Als Favorit', favorite: 'Als Favorit',
albumCount_one: '{{count}} Album', albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben', albumCount_other: '{{count}} Alben',
openedInBrowser: 'Im Browser geöffnet',
}, },
favorites: { favorites: {
title: 'Favoriten', title: 'Favoriten',
+11 -15
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
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 { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -50,6 +50,7 @@ export default function ArtistDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [radioLoading, setRadioLoading] = useState(false); const [radioLoading, setRadioLoading] = useState(false);
const [isStarred, setIsStarred] = useState(false); const [isStarred, setIsStarred] = useState(false);
const [openedLink, setOpenedLink] = useState<string | null>(null);
const playTrack = usePlayerStore(state => state.playTrack); const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue); const enqueue = usePlayerStore(state => state.enqueue);
@@ -77,15 +78,10 @@ export default function ArtistDetail() {
}); });
}, [id]); }, [id]);
const openLink = (url: string, title: string) => { const openLink = (url: string, key: string) => {
const label = `browser_${Date.now()}`; open(url);
new WebviewWindow(label, { setOpenedLink(key);
url, setTimeout(() => setOpenedLink(null), 2500);
title,
width: 1100,
height: 780,
center: true,
});
}; };
const toggleStar = async () => { const toggleStar = async () => {
@@ -192,14 +188,14 @@ 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!, `${artist.name} — Last.fm`)}> <button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
<LastfmIcon size={14} /> <LastfmIcon size={14} />
Last.fm {openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
</button> </button>
)} )}
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}> <button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
<ExternalLink size={14} /> <ExternalLink size={14} />
Wikipedia {openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
</button> </button>
</div> </div>
)} )}
+3 -3
View File
@@ -478,15 +478,15 @@
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: flex-start;
gap: var(--space-3); gap: var(--space-3);
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.queue-current-cover { .queue-current-cover {
width: 72px; width: 90px;
height: 72px; height: 90px;
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;