diff --git a/CHANGELOG.md b/CHANGELOG.md index 6942b786..b55f09e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.0.10] - 2026-03-14 + +### Added +- **Active Track Highlighting**: The currently playing song is highlighted in album tracklists with a subtle pulsing accent background and a play icon — persists when navigating away and returning. +- **Marquee Title in Fullscreen Player**: Long song titles now scroll smoothly as a marquee instead of being cut off. +- **Clickable Artist / Album in Player Bar**: Clicking the artist name navigates to the artist page; clicking the song title navigates to the album page. Same behaviour in the Queue panel's now-playing strip. +- **Linux App Menu Category**: Application now appears under **Multimedia** in desktop application menus (GNOME, KDE, etc.) instead of "Other". +- **Windows MSI Upgrade Support**: Added stable `upgradeCode` GUID so the MSI installer recognises previous versions and upgrades in-place without requiring manual uninstallation first. + +### Fixed +- **Drag & Drop (macOS / Windows)**: Queue reordering now works correctly on macOS WKWebView and Windows WebView2. The previous fix cleared index refs synchronously in `onDragEnd`, which fires before `drop` on both platforms — refs are now cleared with a short delay so `onDropQueue` can read the correct source and destination indices. +- **Settings Dropdowns**: Language and theme selects now have a clearly visible border (was invisible against the card background). +- **Tracklist Format Column**: Removed file size and kHz from the format column — codec and bitrate only. Column moved to the far right, after duration. Width is now dynamic (`auto`). +- **`tauri.conf.json`**: Fixed invalid placement of `shortDescription`/`longDescription` (were incorrectly nested under `bundle.linux`, now at `bundle` level). Removed invalid `nsis.allowDowngrades` field. + +### Changed +- **Favorites Icon**: Replaced the incorrect fork icon with a star icon in the Random Mix page, consistent with all other pages. +- **Sidebar**: Removed drag-to-resize handle. Width now adapts dynamically to the viewport via `clamp(180px, 15vw, 220px)`. +- **About Section**: Added "Developed with the support of Claude Code by Anthropic" credit. Fixed "weiterzugeben" wording in German MIT licence text. +- **Minimize to Tray**: Now disabled by default. + ## [1.0.9] - 2026-03-13 ### Added diff --git a/package.json b/package.json index 19b55714..27e7b084 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.9", + "version": "1.0.10", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a6d3f38a..bd51000b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.0.9" +version = "1.0.10" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8f85e118..4c29e7f3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.9" +version = "1.0.10" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 49560d02..6f4ac1bb 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.0.9", + "version": "1.0.10", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", @@ -45,10 +45,18 @@ "icons/icon.icns", "icons/icon.ico" ], + "category": "Music", + "shortDescription": "A sleek music player for Subsonic-compatible servers", + "longDescription": "Psysonic is a desktop music player for Subsonic-compatible servers such as Navidrome and Gonic. It features a modern Catppuccin-themed UI with glassmorphism effects, gapless playback, a fullscreen ambient player, play queue management with drag-and-drop, scrobbling support, and multi-server profiles.", "linux": { "appimage": { "bundleMediaFramework": true } + }, + "windows": { + "wix": { + "upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c" + } } } } diff --git a/src/App.tsx b/src/App.tsx index 0a54541a..8492396a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,12 +65,10 @@ function AppShell() { fn(); }, [currentTrack, isPlaying]); - const [sidebarWidth, setSidebarWidth] = useState(220); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('psysonic_sidebar_collapsed') === 'true'; }); const [queueWidth, setQueueWidth] = useState(300); - const [isDraggingSidebar, setIsDraggingSidebar] = useState(false); const [isDraggingQueue, setIsDraggingQueue] = useState(false); useEffect(() => { @@ -78,24 +76,18 @@ function AppShell() { }, [isSidebarCollapsed]); const handleMouseMove = useCallback((e: MouseEvent) => { - if (isDraggingSidebar) { - // Limit sidebar width between 180px and 400px - const newWidth = Math.max(180, Math.min(e.clientX, 400)); - setSidebarWidth(newWidth); - } else if (isDraggingQueue) { - // Limit queue width between 250px and 500px. Queue is on the right. + if (isDraggingQueue) { const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500)); setQueueWidth(newWidth); } - }, [isDraggingSidebar, isDraggingQueue]); + }, [isDraggingQueue]); const handleMouseUp = useCallback(() => { - setIsDraggingSidebar(false); setIsDraggingQueue(false); }, []); useEffect(() => { - if (isDraggingSidebar || isDraggingQueue) { + if (isDraggingQueue) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'col-resize'; @@ -111,13 +103,13 @@ function AppShell() { window.removeEventListener('mouseup', handleMouseUp); document.body.classList.remove('is-dragging'); }; - }, [isDraggingSidebar, isDraggingQueue, handleMouseMove, handleMouseUp]); + }, [isDraggingQueue, handleMouseMove, handleMouseUp]); return (
e.preventDefault()} @@ -126,14 +118,6 @@ function AppShell() { isCollapsed={isSidebarCollapsed} toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)} /> -
{ - e.preventDefault(); - setIsDraggingSidebar(true); - }} - style={{ display: isSidebarCollapsed ? 'none' : 'block' }} - />
diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index db35271c..5558e553 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -15,6 +15,43 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } +function MarqueeTitle({ title }: { title: string }) { + const containerRef = useRef(null); + const textRef = useRef(null); + const [scrollAmount, setScrollAmount] = useState(0); + + const measure = useCallback(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) return; + // Temporarily make span inline-block to get its natural width + text.style.display = 'inline-block'; + const textWidth = text.getBoundingClientRect().width; + text.style.display = ''; + const overflow = textWidth - container.clientWidth; + setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0); + }, []); + + useEffect(() => { + measure(); + const ro = new ResizeObserver(measure); + if (containerRef.current) ro.observe(containerRef.current); + return () => ro.disconnect(); + }, [title, measure]); + + return ( +
+ 0 ? 'fs-title-marquee' : ''} + style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}} + > + {title} + +
+ ); +} + // ─── Crossfading blurred background ─────────────────────────────────────────── const FsBg = memo(function FsBg({ url }: { url: string }) { const [layers, setLayers] = useState>(() => @@ -173,7 +210,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
-

{currentTrack?.title ?? '—'}

+

{currentTrack?.album ?? ''} {currentTrack?.year ? ` · ${currentTrack.year}` : ''} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 5ffd8d36..4751c6fb 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage from './CachedImage'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -16,6 +17,7 @@ function formatTime(seconds: number): string { export default function PlayerBar() { const { t } = useTranslation(); + const navigate = useNavigate(); const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore(); const duration = currentTrack?.duration ?? 0; @@ -68,10 +70,20 @@ export default function PlayerBar() { )}

-
+
currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} + > {currentTrack?.title ?? t('player.noTitle')}
-
+
currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} + > {currentTrack?.artist ?? '—'}
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index b719ed1c..73f2829c 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -4,6 +4,7 @@ import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -111,6 +112,7 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: ( export default function QueuePanel() { const { t } = useTranslation(); + const navigate = useNavigate(); const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -169,35 +171,44 @@ export default function QueuePanel() { }; const onDragEnd = () => { - isDraggingInternalRef.current = false; - draggedIdxRef.current = null; - dragOverIdxRef.current = null; + // Reset visual state immediately. setDraggedIdx(null); setDragOverIdx(null); + // Delay clearing refs so onDropQueue can read them first. + // On macOS WKWebView and Windows WebView2, dragend fires before drop + // (spec violation), so synchronously clearing refs here loses the + // drag source/destination indices before onDropQueue runs. + setTimeout(() => { + isDraggingInternalRef.current = false; + draggedIdxRef.current = null; + dragOverIdxRef.current = null; + }, 200); }; const onDropQueue = async (e: React.DragEvent) => { e.preventDefault(); - // Capture refs before resetting — dragend may have already cleared them on Mac. + // Refs are still valid here — onDragEnd delays clearing them so they survive + // the macOS/WebView2 dragend-before-drop race condition. const fromIdx = draggedIdxRef.current; const toIdx = dragOverIdxRef.current ?? queue.length; + // Cancel the pending timeout cleanup and clear refs immediately. isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; setDraggedIdx(null); setDragOverIdx(null); - // Read dataTransfer — set during dragstart, outlives dragend on all platforms. + // Read dataTransfer — set during dragstart, survives dragend on all platforms. + // Used as fallback for fromIdx in case refs somehow weren't set. let parsedData: any = null; try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch { /* ignore */ } - // Internal reorder: prefer ref value (fast path), fall back to dataTransfer - // for the Mac dragend-before-drop race condition. + // Internal reorder: refs are the primary source; dataTransfer is the fallback. const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null); if (reorderFrom !== null) { if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx); @@ -259,9 +270,19 @@ export default function QueuePanel() { )}
-

{currentTrack.title}

+

currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} + >{currentTrack.title}

{currentTrack.year &&
{currentTrack.year}
} -
{currentTrack.album}
+
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} + >{currentTrack.album}
diff --git a/src/i18n.ts b/src/i18n.ts index 265d124d..c0922d04 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -258,7 +258,8 @@ const enTranslation = { aboutLicenseText: 'MIT — free to use, modify, and distribute.', aboutRepo: 'Source Code on GitHub', aboutVersion: 'Version', - aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js' + aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js', + aboutAiCredit: 'Developed with the support of Claude Code by Anthropic' }, help: { title: 'Help', @@ -612,10 +613,11 @@ const deTranslation = { aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.', aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes', aboutLicense: 'Lizenz', - aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.', + aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.', aboutRepo: 'Quellcode auf GitHub', aboutVersion: 'Version', - aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js' + aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js', + aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic' }, help: { title: 'Hilfe', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 5527994a..585ec16d 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -31,11 +31,10 @@ function formatSize(bytes?: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } -function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: number }): string { +function codecLabel(song: { suffix?: string; bitRate?: number }): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); if (song.bitRate) parts.push(`${song.bitRate} kbps`); - if (song.samplingRate) parts.push(`${(song.samplingRate / 1000).toFixed(1)} kHz`); return parts.join(' · '); } @@ -101,6 +100,7 @@ export default function AlbumDetail() { const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); + const currentTrack = usePlayerStore(s => s.currentTrack); const [album, setAlbum] = useState> | null>(null); const [relatedAlbums, setRelatedAlbums] = useState([]); const [ratings, setRatings] = useState>({}); @@ -378,10 +378,10 @@ export default function AlbumDetail() {
#
{t('albumDetail.trackTitle')}
{hasVariousArtists &&
{t('albumDetail.trackArtist')}
} -
{t('albumDetail.trackFormat')}
{t('albumDetail.trackFavorite')}
{t('albumDetail.trackRating')}
{t('albumDetail.trackDuration')}
+
{t('albumDetail.trackFormat')}
{(() => { @@ -405,7 +405,7 @@ export default function AlbumDetail() { {discs.get(discNum)!.map((song, i) => (
setHoveredSongId(song.id)} onMouseLeave={() => setHoveredSongId(null)} onDoubleClick={() => handlePlaySong(song)} @@ -432,12 +432,14 @@ export default function AlbumDetail() { >
handlePlaySong(song)} > {hoveredSongId === song.id ? - : (song.track ?? i + 1)} + : currentTrack?.id === song.id + ? + : (song.track ?? i + 1)}
{song.title} @@ -447,14 +449,6 @@ export default function AlbumDetail() { {song.artist}
)} -
- {(song.suffix || song.bitRate) && ( - - {codecLabel(song)} - {song.size ? · {formatSize(song.size)} : null} - - )} -
+
+ {(song.suffix || song.bitRate) && ( + + {codecLabel(song)} + + )} +
))}
diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 72dab636..e5fe0240 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; -import { Play, HandMetal, RefreshCw } from 'lucide-react'; +import { Play, Star, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; function formatDuration(seconds: number): string { @@ -133,7 +133,7 @@ export default function RandomMix() { data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')} style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }} > - +
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index bc059313..eb5e45fc 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -374,7 +374,7 @@ export default function Settings() { Psysonic
- {t('settings.aboutVersion')} 1.0.9 + {t('settings.aboutVersion')} 1.0.10
@@ -397,6 +397,10 @@ export default function Settings() { Stack {t('settings.aboutBuiltWith')} +
+ AI + {t('settings.aboutAiCredit')} +