From 1e599d9636abaa3b1a0d9ea14fddff9059005191 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 14 Mar 2026 12:33:58 +0100 Subject: [PATCH] feat: Search results page, gapless removal, UI polish (v1.0.11) - Add full search results page (/search?q=) with artist, album, song sections - Fix search results column alignment (fixed-width Format column eliminates per-grid auto-sizing variance) - Remove experimental gapless playback (caused song skipping and beginning cutoffs) - Add known limitations to README and CHANGELOG (seeking, DnD reliability) - Update GitHub Actions to Node.js 24 (checkout@v5, setup-node@v5) - Add align-items: center to tracklist-header CSS Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 12 +-- CHANGELOG.md | 16 ++++ README.md | 2 + package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 + src/components/LiveSearch.tsx | 6 ++ src/components/Sidebar.tsx | 2 +- src/i18n.ts | 6 ++ src/pages/SearchResults.tsx | 134 ++++++++++++++++++++++++++++++++++ src/pages/Settings.tsx | 2 +- src/store/playerStore.ts | 57 +-------------- src/styles/components.css | 1 + 15 files changed, 181 insertions(+), 67 deletions(-) create mode 100644 src/pages/SearchResults.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4848926..1b6c55b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,9 @@ jobs: release_id: ${{ steps.create-release.outputs.result }} package_version: ${{ steps.get-version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: lts/* - name: get version @@ -56,9 +56,9 @@ jobs: args: '' runs-on: ${{ matrix.settings.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: lts/* - name: install Rust stable @@ -80,7 +80,7 @@ jobs: contents: write runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: install dependencies run: | @@ -97,7 +97,7 @@ jobs: chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh - name: setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: lts/* diff --git a/CHANGELOG.md b/CHANGELOG.md index b55f09e7..0d2a2fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.11] - 2026-03-14 + +### Added +- **Search Results Page**: Pressing Enter in the search bar now navigates to a dedicated full search results page showing artists, albums, and songs with proper column layout and headers. + +### Fixed +- **Search Results Column Alignment**: Artist and album columns in the search results song list are now correctly aligned with their column headers. +- **Search Results Header Alignment**: Fixed column header labels not aligning with song row content (root cause: `auto`-width Format column was sized independently per grid row). + +### Changed +- **Gapless Playback removed**: Removed the experimental gapless playback feature. It caused intermittent song skipping and beginning cutoffs and was not reliable enough to ship. Standard sequential playback is used instead. + +### Known Issues +- **Seeking**: Seeking may occasionally be unreliable, particularly on Linux/GStreamer. Being actively investigated. +- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows. Being actively worked on. + ## [1.0.10] - 2026-03-14 ### Added diff --git a/README.md b/README.md index 890d304f..3e8562f3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Designed specifically for users hosting their own music via Navidrome or other S ## ● 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. ## 📥 Installation diff --git a/package.json b/package.json index 27e7b084..e7edfe4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.10", + "version": "1.0.11", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bd51000b..3fa69843 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.0.10" +version = "1.0.11" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c29e7f3..dbc8327e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.10" +version = "1.0.11" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f4ac1bb..c60f95dc 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.10", + "version": "1.0.11", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 8492396a..aaaf83be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import Statistics from './pages/Statistics'; import Playlists from './pages/Playlists'; import Help from './pages/Help'; import RandomAlbums from './pages/RandomAlbums'; +import SearchResults from './pages/SearchResults'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import { useAuthStore } from './store/authStore'; @@ -137,6 +138,7 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index c2eb3bc5..5e4c1997 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -69,6 +69,12 @@ export default function LiveSearch() { value={query} onChange={e => setQuery(e.target.value)} onFocus={() => results && setOpen(true)} + onKeyDown={e => { + if (e.key === 'Enter' && query.trim()) { + setOpen(false); + navigate(`/search?q=${encodeURIComponent(query.trim())}`); + } + }} aria-autocomplete="list" aria-controls="search-results" aria-expanded={open} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9e22f0b7..a77e0652 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -23,7 +23,7 @@ const navItems = [ ]; function isNewer(latest: string, current: string): boolean { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); + const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number); const [lMaj, lMin, lPat] = parse(latest); const [cMaj, cMin, cPat] = parse(current); if (lMaj !== cMaj) return lMaj > cMaj; diff --git a/src/i18n.ts b/src/i18n.ts index c0922d04..37a273d7 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -44,6 +44,9 @@ const enTranslation = { albums: 'Albums', songs: 'Songs', clearLabel: 'Clear search', + title: 'Search', + resultsFor: 'Results for "{{query}}"', + album: 'Album', }, nowPlaying: { tooltip: 'Who is listening?', @@ -402,6 +405,9 @@ const deTranslation = { albums: 'Alben', songs: 'Songs', clearLabel: 'Suche leeren', + title: 'Suche', + resultsFor: 'Ergebnisse für „{{query}}"', + album: 'Album', }, nowPlaying: { tooltip: 'Wer hört was?', diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx new file mode 100644 index 00000000..a9b5c7d9 --- /dev/null +++ b/src/pages/SearchResults.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Play, Search } from 'lucide-react'; +import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; +import { usePlayerStore } from '../store/playerStore'; +import AlbumRow from '../components/AlbumRow'; +import ArtistRow from '../components/ArtistRow'; +import { useTranslation } from 'react-i18next'; + +function formatDuration(s: number) { + return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; +} + +export default function SearchResults() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const query = params.get('q') ?? ''; + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const playTrack = usePlayerStore(s => s.playTrack); + const currentTrack = usePlayerStore(s => s.currentTrack); + + useEffect(() => { + if (!query.trim()) { setResults(null); return; } + setLoading(true); + search(query, { artistCount: 20, albumCount: 20, songCount: 50 }) + .then(r => setResults(r)) + .finally(() => setLoading(false)); + }, [query]); + + const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + + const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { + playTrack({ + id: song.id, title: song.title, artist: song.artist, album: song.album, + albumId: song.albumId, artistId: song.artistId, duration: song.duration, + coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, + suffix: song.suffix, userRating: song.userRating, + }, list.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, year: s.year, bitRate: s.bitRate, + suffix: s.suffix, userRating: s.userRating, + }))); + }; + + return ( +
+
+

+ + {query ? t('search.resultsFor', { query }) : t('search.title')} +

+
+ + {loading && ( +
+ )} + + {!loading && query && !hasResults && ( +
{t('search.noResults', { query })}
+ )} + + {!loading && results && ( + <> + {results.artists.length > 0 && ( + + )} + + {results.albums.length > 0 && ( + + )} + + {results.songs.length > 0 && ( +
+
+

{t('search.songs')}

+
+
+
+
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackArtist')}
+
{t('search.album')}
+
{t('albumDetail.trackFormat')}
+
{t('albumDetail.trackDuration')}
+
+ {results.songs.map(song => ( +
playSong(song, results.songs)} + role="row" + draggable + onDragStart={e => { + e.dataTransfer.effectAllowed = 'copy'; + const track = { + id: song.id, title: song.title, artist: song.artist, album: song.album, + albumId: song.albumId, artistId: song.artistId, duration: song.duration, + coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, + suffix: song.suffix, userRating: song.userRating, + }; + e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); + }} + > + +
+ {song.title} +
+
{song.artist}
+
{song.album}
+ + {[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')} + + + {formatDuration(song.duration)} + +
+ ))} +
+
+ )} + + )} +
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index eb5e45fc..e3c7db66 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.10 + {t('settings.aboutVersion')} 1.0.11
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 553c7bc0..ae35b980 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -30,7 +30,6 @@ interface PlayerState { currentTime: number; volume: number; howl: Howl | null; - prefetched: Map; scrobbled: boolean; // Actions @@ -46,8 +45,7 @@ interface PlayerState { setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[]) => void; clearQueue: () => void; - prefetchUpcoming: (fromIndex: number, queue: Track[]) => void; - + isQueueVisible: boolean; toggleQueue: () => void; @@ -81,7 +79,6 @@ let gstSeeking = false; // true while GStreamer is processing a seek let pendingSeekTime: number | null = null; // queue at most one seek let gstSeekWatchdog: ReturnType | null = null; // safety release if onseek never fires let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here -let gaplessPrewarmedId: string | null = null; // track id whose prefetched Howl has been silently pre-started let hangLastTime = -1; // last observed currentTime for hang detection let hangStallTime = 0; // Date.now() when currentTime last moved @@ -131,7 +128,6 @@ export const usePlayerStore = create()( currentTime: 0, volume: 0.8, howl: null, - prefetched: new Map(), scrobbled: false, isQueueVisible: true, isFullscreenOpen: false, @@ -172,29 +168,11 @@ export const usePlayerStore = create()( pendingSeekTime = null; hangLastTime = -1; hangStallTime = 0; - gaplessPrewarmedId = null; const newQueue = queue ?? state.queue; const idx = newQueue.findIndex(t => t.id === track.id); - // Reuse a prefetched Howl if available — it's already connected and buffering - const prefetchMap = state.prefetched; - let howl: Howl; - let gaplessHandoff = false; - if (prefetchMap.has(track.id)) { - howl = prefetchMap.get(track.id)!; - prefetchMap.delete(track.id); - set({ prefetched: new Map(prefetchMap) }); - if (howl.playing()) { - // Gapless: pipeline already running — pause, seek to 0, then play - gaplessHandoff = true; - howl.pause(); - hangRecoveryPos = 0; // onplay will seek to position 0 - } - howl.volume(state.volume); - } else { - howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume }); - } + const howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume }); howl.on('play', () => { set({ isPlaying: true }); @@ -247,18 +225,6 @@ export const usePlayerStore = create()( return; } - // Gapless pre-warm: start next track's Howl silently ~1s before end - if (!gaplessPrewarmedId && dur > 2 && dur - cur < 1.0) { - const { queue: q, queueIndex: qi, repeatMode: rm, prefetched: pf } = get(); - const nextIdx = qi + 1; - const nextTrack = nextIdx < q.length ? q[nextIdx] - : (rm === 'all' && q.length > 0 ? q[0] : null); - if (nextTrack && pf.has(nextTrack.id)) { - const nh = pf.get(nextTrack.id)!; - if (!nh.playing()) { nh.volume(0); nh.play(); gaplessPrewarmedId = nextTrack.id; } - } - } - // Scrobble at 50% if (prog >= 0.5 && !get().scrobbled) { set({ scrobbled: true }); @@ -267,8 +233,6 @@ export const usePlayerStore = create()( } }, 500); - // Prefetch next 3 - get().prefetchUpcoming(idx + 1, newQueue); }); howl.on('end', () => { @@ -406,23 +370,6 @@ export const usePlayerStore = create()( syncQueueToServer([], null, 0); }, - // Internal: prefetch next N tracks - prefetchUpcoming: (fromIndex: number, queue: Track[]) => { - const { prefetched } = get(); - // Unload and clear old prefetches to prevent memory leaks - prefetched.forEach((h, id) => { - h.unload(); - }); - prefetched.clear(); - - const toFetch = queue.slice(fromIndex, fromIndex + 3); - toFetch.forEach(track => { - const url = buildStreamUrl(track.id); - const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false }); - prefetched.set(track.id, h); - }); - set({ prefetched: new Map(prefetched) }); - }, // Playlist management reorderQueue: (startIndex: number, endIndex: number) => { const { queue, queueIndex, currentTrack } = get(); diff --git a/src/styles/components.css b/src/styles/components.css index 9cf495d6..a09db66a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -569,6 +569,7 @@ display: grid; grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto; gap: var(--space-3); + align-items: center; padding: var(--space-2) var(--space-3); font-size: 11px; font-weight: 600;