Compare commits

...

2 Commits

Author SHA1 Message Date
Psychotoxical 8ec642f368 fix: Random Mix improvements, queue UX fixes (v1.4.3)
- Random Mix: remove redundant Play All button in genre mix section
- Random Mix: Play All disabled with live counter during genre mix loading
- Random Mix: cap genre fetch at 50 genres to prevent over-fetching
- Random Mix: cache-bust getRandomSongs to always get fresh results
- Random Mix: clear songs on remix to fix display/play mismatch
- Queue: show song count and total duration in header
- Queue: context-active class keeps hover highlight on right-click
- Context Menu: add Favorite option for queue-item type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:38:27 +01:00
Psychotoxical 73f836b2ee fix: AppImage EGL crash on modern distros, update link capability (v1.4.2)
- Switch Linux CI build from ubuntu-22.04 to ubuntu-24.04 — bundles
  WebKitGTK 2.44 which uses eglGetPlatformDisplay instead of the
  deprecated eglGetDisplay(EGL_DEFAULT_DISPLAY) that fails on Mesa 25.x
  (CachyOS/Arch with RDNA 4 and other modern hardware)
- Add EGL_PLATFORM=x11 to AppRun as additional safeguard for XWayland
- Fix shell:allow-open capability missing URL scope (update toast link
  was silently blocked by Tauri v2 without explicit allow-list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:26:33 +01:00
12 changed files with 95 additions and 23 deletions
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
@@ -129,7 +129,7 @@ jobs:
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11\nexport EGL_PLATFORM=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
+29
View File
@@ -5,6 +5,35 @@ 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.4.3] - 2026-03-16
### Fixed
#### Random Mix — Genre Mix
- **Second "Play All" button removed**: The genre mix section had a redundant play button below the super-genre selector. The top-right button is now context-aware — it plays the genre mix when one is active, otherwise the regular mix.
- **"Play All" disabled during genre mix loading**: The button now stays grayed out with a live progress counter (`n / 50`) until all songs are fully loaded. Clicking while the list was still building sent only the songs loaded so far.
- **Over-fetching fixed**: Genre mix previously fetched up to 100+ songs and sliced to 50 at the end. Now the matched genre list is capped at 50 (randomly sampled when more match) so the total fetch stays close to 50 with no wasted server I/O.
- **Regular mix cache-busting**: `getRandomSongs` requests now include a timestamp parameter, preventing browser/axios from returning a cached response and showing the same list on every remix.
- **Display/state mismatch on remix**: Clicking "Mischen" now clears the current list immediately, ensuring the spinner is shown and the displayed songs always match what "Play All" would send.
#### Queue Panel
- **Hover highlight lost on right-click**: Queue items now retain their hover highlight while a context menu is open for them (`.context-active` CSS class).
- **Song count and total duration**: The queue header now shows the number of tracks and total runtime below the title (e.g. `12 tracks · 47:32`).
#### Context Menu
- **"Favorite" option added for queue items**: Right-clicking a queue item now includes a "Favorite" option, consistent with the song context menu.
## [1.4.2] - 2026-03-16
### Fixed
#### Linux AppImage — Modern Distro Compatibility
- **Build upgraded to Ubuntu 24.04**: The AppImage was previously built on Ubuntu 22.04 with WebKitGTK 2.36. On modern distros (CachyOS, Arch, etc.) with Mesa 25.x, `eglGetDisplay(EGL_DEFAULT_DISPLAY)` returns `EGL_BAD_PARAMETER` and aborts immediately because newer Mesa no longer accepts implicit platform detection. Building on Ubuntu 24.04 bundles WebKitGTK 2.44 which uses the correct `eglGetPlatformDisplay` API.
- **`EGL_PLATFORM=x11` added to AppRun**: Additional safeguard that explicitly tells Mesa's EGL loader to use the X11 platform when the app is running under XWayland.
#### Shell — Update Link
- `shell:allow-open` capability now includes a URL scope (`https://**`), fixing the update toast link that silently did nothing in Tauri v2 without an explicit allow-list.
## [1.4.1] - 2026-03-16
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.4.1",
"version": "1.4.3",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.4.1"
version = "1.4.2"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -7,7 +7,7 @@
"permissions": [
"core:default",
"shell:default",
"shell:allow-open",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.4.1",
"version": "1.4.3",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -163,7 +163,7 @@ export async function getAlbumList(
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size };
const params: Record<string, string | number> = { size, _t: Date.now() };
if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? [];
+3
View File
@@ -215,6 +215,9 @@ export default function ContextMenu() {
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
+19 -2
View File
@@ -128,6 +128,7 @@ export default function QueuePanel() {
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const contextMenu = usePlayerStore(s => s.contextMenu);
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -254,7 +255,23 @@ export default function QueuePanel() {
}}
>
<div className="queue-header">
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
<div>
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
const dur = h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
return (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</div>
);
})()}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
@@ -343,7 +360,7 @@ export default function QueuePanel() {
<div
key={`${track.id}-${idx}`}
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''}`}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => playTrack(track, queue)}
onContextMenu={(e) => {
e.preventDefault();
+6 -2
View File
@@ -373,7 +373,9 @@ const enTranslation = {
hide: 'Hide',
close: 'Close',
nextTracks: 'Next Tracks',
emptyQueue: 'The queue is empty.'
emptyQueue: 'The queue is empty.',
trackSingular: 'track',
trackPlural: 'tracks',
},
statistics: {
title: 'Statistics',
@@ -790,7 +792,9 @@ const deTranslation = {
hide: 'Verbergen',
close: 'Schließen',
nextTracks: 'Nächste Titel',
emptyQueue: 'Die Warteschlange ist leer.'
emptyQueue: 'Die Warteschlange ist leer.',
trackSingular: 'Titel',
trackPlural: 'Titel',
},
statistics: {
title: 'Statistiken',
+30 -11
View File
@@ -58,9 +58,11 @@ export default function RandomMix() {
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixComplete, setGenreMixComplete] = useState(false);
const fetchSongs = () => {
setLoading(true);
setSongs([]);
getRandomSongs(50)
.then(fetched => {
setSongs(fetched);
@@ -96,7 +98,11 @@ export default function RandomMix() {
});
const handlePlayAll = () => {
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
if (selectedSuperGenre && genreMixSongs.length > 0) {
playTrack(genreMixSongs[0], genreMixSongs);
} else if (filteredSongs.length > 0) {
playTrack(filteredSongs[0], filteredSongs);
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
@@ -126,10 +132,13 @@ export default function RandomMix() {
const loadGenreMix = async (superGenreId: string) => {
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
if (!sg) return;
const matched = serverGenres
const allMatched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value);
.map(sg2 => sg2.value)
.sort(() => Math.random() - 0.5);
const matched = allMatched.slice(0, 50);
setGenreMixLoading(true);
setGenreMixComplete(false);
setGenreMixSongs([]);
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
@@ -156,6 +165,7 @@ export default function RandomMix() {
return s.slice(0, 50);
});
setGenreMixLoading(false);
setGenreMixComplete(true);
};
@@ -168,9 +178,23 @@ export default function RandomMix() {
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
</button>
{(() => {
const isGenreLoading = selectedSuperGenre && !genreMixComplete;
const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
return (
<button
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
onClick={handlePlayAll}
disabled={isDisabled}
>
{isGenreLoading ? (
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, 50)} / 50</>
) : (
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
)}
</button>
);
})()}
</div>
</div>
@@ -302,11 +326,6 @@ export default function RandomMix() {
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
</button>
</div>
</div>
{genreMixLoading && genreMixSongs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
+1 -1
View File
@@ -570,7 +570,7 @@
/* Prevent child elements from stealing dragenter/dragleave events */
.queue-item > * { pointer-events: none; }
.queue-item:hover {
.queue-item:hover, .queue-item.context-active {
background: var(--bg-hover);
color: var(--text-primary);
}