From 43c656dfc36fcbd5043648aa20909e62e625a9bf Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Mon, 30 Mar 2026 22:56:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1.23.0=20=E2=80=94=20Advanced=20Search?= =?UTF-8?q?,=20Genre=20Mix=20overhaul,=20Playlist=20append,=20Contributors?= =?UTF-8?q?=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 27 +++ README.md | 8 +- package.json | 2 +- packages/aur/PKGBUILD | 3 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 + src/components/GenreFilterBar.tsx | 4 +- src/components/LiveSearch.tsx | 12 +- src/components/QueuePanel.tsx | 19 +- src/i18n.ts | 110 ++++++++-- src/pages/AdvancedSearch.tsx | 354 ++++++++++++++++++++++++++++++ src/pages/Favorites.tsx | 20 +- src/pages/RandomMix.tsx | 152 ++++++------- src/pages/Settings.tsx | 77 ++++++- src/styles/components.css | 40 +++- src/styles/theme.css | 26 +-- 16 files changed, 714 insertions(+), 144 deletions(-) create mode 100644 src/pages/AdvancedSearch.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eb9e38..48ef344e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ 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.23.0] - 2026-03-30 + +### Added + +- **Advanced Search**: New dedicated page (`/search/advanced`) reachable via the filter icon in the search bar. Supports free-text search combined with genre filter (dropdown from server), year range (from/to), and result-type toggle (All / Artists / Albums / Songs). Search logic: text query uses `search3` with client-side genre/year filtering; genre-only uses `getAlbumsByGenre` + random songs from that genre; year-only uses `getAlbumList(byYear)`. Results show in the standard ArtistRow / AlbumRow / tracklist layout with drag-to-queue and context menu support. +- **Genre Mix — Server-native genres**: The Genre Mix panel in Random Mix now shows the top 20 genres from the server sorted by song count, instead of hardcoded keyword-based "Super Genre" groups. Only genres with at least one song and no audiobook keywords are shown. Clicking a badge fetches up to 50 random songs from exactly that genre. +- **Genre Mix — Shuffle button**: A ↺ button appears when the server has more than 20 genres. Clicking it picks a fresh random selection of 20 from all available genres, replacing the current badges without triggering a search. +- **Favorites — Play All**: "Play All" button (primary style) added next to "Add all to queue" in the Favorites → Songs section. Starts playback immediately from the first favorited song. +- **Playlist Load — Append mode**: The playlist load modal now has two action buttons per playlist: ▶ replaces the queue and starts playback (previous behavior), ≡+ appends all tracks to the existing queue without interrupting playback. + +### Fixed + +- **Replay Gain** *(contributed by [@trbn1](https://github.com/trbn1))*: Replay Gain metadata (track gain, album gain, peaks) is now correctly propagated to the audio engine across all track-construction sites via the new `songToTrack()` helper. Previously tracks built inline missed the `replayGain` field, causing the engine to apply 0 dB gain regardless of tags. + +### Changed + +- **Genre Mix description**: Panel subtitle updated to explain that badges represent the top 20 genres by song count and that clicking loads a random mix from that genre. +- **Random Mix — Filter panel**: Added a short descriptive hint below the "Filters" heading explaining that genre tags and artist names in the tracklist are clickable to add them to the blacklist. +- **Playlist Load modal**: Width increased from 400 px to 560 px (90 vw cap) so long playlist names are readable without truncation. +- **Settings — Contributors**: Contributors section is now a collapsible table. Each entry shows the contributor's GitHub avatar, `@username` (linked to their profile), a version badge, and a bullet list of their specific contributions. [@trbn1](https://github.com/trbn1) added for Replay Gain fix (PR #9). + +### Theme Fixes + +- **Powerslave**: Album card play button no longer flickers between gradient and flat accent color on hover — explicit `:hover` gradient override added. Sidebar stripe pattern replaced with soft radial-gradient cloud wisps. + +--- + ## [1.22.0] - 2026-03-30 ### Added diff --git a/README.md b/README.md index b2a00536..75ca0997 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ Designed specifically for users hosting their own music via Navidrome or other S - [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll) - [x] Multi-server support - [x] IndexedDB image caching -- [x] Random Mix with keyword filter & Super Genre mix +- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable) +- [x] Advanced Search (text + genre + year + result-type filters) - [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer - [x] Internationalization (English, German, French, Dutch, Chinese) - [x] AUR package (Arch / CachyOS) @@ -63,7 +64,7 @@ Designed specifically for users hosting their own music via Navidrome or other S - [x] Font picker (10 UI fonts) ### 📋 Planned -- [ ] General UI polish & visual refinement +- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes - [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels - [ ] More languages @@ -71,7 +72,8 @@ Designed specifically for users hosting their own music via Navidrome or other S ## ● Known Limitations -- **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. +- **Linux (Wayland — drag & drop cursor)**: On some Wayland compositors the drag cursor may show no visual indicator during DnD. This is a WebKitGTK limitation unrelated to the drop operation itself, which always works correctly. +- **FLAC without SEEKTABLE**: FLAC files that were encoded without a SEEKTABLE index cannot be seeked. The seek silently fails; playback continues from the current position. ## 📥 Installation diff --git a/package.json b/package.json index 8c6f6035..e9b86547 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.22.0", + "version": "1.23.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 093e8329..d6e826c8 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.22.0 +pkgver=1.23.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') @@ -9,7 +9,6 @@ license=('GPL-3.0-only') depends=( 'webkit2gtk-4.1' 'gtk3' - 'libayatana-appindicator' 'openssl' 'alsa-lib' ) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2cd1ba3b..1375c094 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.22.0", + "version": "1.23.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 5e9bdfc8..8b1e7476 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import Statistics from './pages/Statistics'; import Help from './pages/Help'; import RandomAlbums from './pages/RandomAlbums'; import SearchResults from './pages/SearchResults'; +import AdvancedSearch from './pages/AdvancedSearch'; import NowPlayingPage from './pages/NowPlaying'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; @@ -242,6 +243,7 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/GenreFilterBar.tsx b/src/components/GenreFilterBar.tsx index 00f169ee..ac92f178 100644 --- a/src/components/GenreFilterBar.tsx +++ b/src/components/GenreFilterBar.tsx @@ -119,7 +119,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil /> {dropdownOpen && filteredOptions.length > 0 && ( -
+
e.stopPropagation()}> {filteredOptions.slice(0, 60).map(g => (
add(g)}> {g} @@ -129,7 +129,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil )} {dropdownOpen && filteredOptions.length === 0 && search.length > 0 && ( -
+
e.stopPropagation()}>
{t('common.filterNoGenres')}
)} diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b330d8d7..363660f7 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, Disc3, Users, Music } from 'lucide-react'; +import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; @@ -116,6 +116,16 @@ export default function LiveSearch() { × )} +
{open && ( diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 68050a80..61ac57a9 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; -import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check } from 'lucide-react'; +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'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -60,7 +60,7 @@ function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: ( ); } -function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string) => void }) { +function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string, mode: 'replace' | 'append') => void }) { const { t } = useTranslation(); const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); @@ -96,7 +96,7 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: ( return ( <>
-
e.stopPropagation()} style={{ maxWidth: '400px' }}> +
e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>

{t('queue.loadPlaylist')}

{!loading && playlists.length > 0 && ( @@ -120,7 +120,8 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
{p.name}
- + +
@@ -583,13 +584,17 @@ export default function QueuePanel() { {loadModalOpen && ( setLoadModalOpen(false)} - onLoad={async (id, name) => { + onLoad={async (id, name, mode) => { try { const data = await getPlaylist(id); const tracks: Track[] = data.songs.map(songToTrack); if (tracks.length > 0) { - clearQueue(); - playTrack(tracks[0], tracks); + if (mode === 'append') { + enqueue(tracks); + } else { + clearQueue(); + playTrack(tracks[0], tracks); + } } setActivePlaylist({ id, name }); setLoadModalOpen(false); diff --git a/src/i18n.ts b/src/i18n.ts index 4c8ecc75..f19c8578 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -52,6 +52,19 @@ const enTranslation = { title: 'Search', resultsFor: 'Results for "{{query}}"', album: 'Album', + advanced: 'Advanced Search', + advancedSearchTerm: 'Search term', + advancedSearchPlaceholder: 'Title, album, artist…', + advancedGenre: 'Genre', + advancedAllGenres: 'All genres', + advancedYear: 'Year', + advancedYearFrom: 'from', + advancedYearTo: 'to', + advancedAll: 'All', + advancedSearch: 'Search', + advancedEmpty: 'Enter a search term or select a filter to begin.', + advancedNoResults: 'No results found.', + advancedGenreNote: 'Songs are randomly selected from this genre.', }, nowPlaying: { tooltip: 'Who is listening?', @@ -155,6 +168,7 @@ const enTranslation = { albums: 'Albums', songs: 'Songs', enqueueAll: 'Add all to queue', + playAll: 'Play all', removeSong: 'Remove from favorites', }, randomAlbums: { @@ -197,10 +211,12 @@ const enTranslation = { artistClickHint: 'Click to block this artist', blacklistToggle: 'Keyword Filter', genreMixTitle: 'Genre Mix', - genreMixDesc: 'Select a genre to get a curated random mix', + genreMixDesc: 'Top 20 genres by song count — click to load a random mix', genreMixLoadMore: 'Load 10 more', genreMixNoGenres: 'No genres found on server.', + shuffleGenres: 'Show different genres', filterPanelTitle: 'Filters', + filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.', genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.', }, albums: { @@ -359,7 +375,6 @@ const enTranslation = { aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Developed with the support of Claude Code by Anthropic', aboutContributorsLabel: 'Contributors', - aboutContributors: 'jiezhuo — Chinese translation · nullobject — Seek & waveform fixes', changelog: 'Changelog', showChangelogOnUpdate: "Show 'What's New' on update", showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.", @@ -500,7 +515,8 @@ const enTranslation = { loadPlaylist: 'Load Playlist', loading: 'Loading…', noPlaylists: 'No playlists found.', - load: 'Load', + load: 'Replace queue & play', + appendToQueue: 'Append to queue', delete: 'Delete', deleteConfirm: 'Delete playlist "{{name}}"?', clear: 'Clear', @@ -629,6 +645,19 @@ const deTranslation = { title: 'Suche', resultsFor: 'Ergebnisse für „{{query}}"', album: 'Album', + advanced: 'Erweiterte Suche', + advancedSearchTerm: 'Suchbegriff', + advancedSearchPlaceholder: 'Titel, Album, Künstler…', + advancedGenre: 'Genre', + advancedAllGenres: 'Alle Genres', + advancedYear: 'Jahr', + advancedYearFrom: 'von', + advancedYearTo: 'bis', + advancedAll: 'Alle', + advancedSearch: 'Suchen', + advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.', + advancedNoResults: 'Keine Ergebnisse gefunden.', + advancedGenreNote: 'Songs werden zufällig aus dem Genre gewählt.', }, nowPlaying: { tooltip: 'Wer hört was?', @@ -732,6 +761,7 @@ const deTranslation = { albums: 'Alben', songs: 'Songs', enqueueAll: 'Alle in die Warteschlange', + playAll: 'Alle abspielen', removeSong: 'Aus Favoriten entfernen', }, randomAlbums: { @@ -774,10 +804,12 @@ const deTranslation = { artistClickHint: 'Klicken um diesen Künstler zu sperren', blacklistToggle: 'Keyword-Filter', genreMixTitle: 'Genre-Mix', - genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix', + genreMixDesc: 'Top 20 Genres nach Songanzahl — klicken für einen Zufallsmix', genreMixLoadMore: '10 weitere laden', genreMixNoGenres: 'Keine Genres auf dem Server gefunden.', + shuffleGenres: 'Andere Genres anzeigen', filterPanelTitle: 'Filter', + filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.', genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.', }, albums: { @@ -936,7 +968,6 @@ const deTranslation = { aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic', aboutContributorsLabel: 'Mitwirkende', - aboutContributors: 'jiezhuo — Chinesische Übersetzung · nullobject — Seek & Waveform-Fixes', changelog: 'Changelog', showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.', @@ -1077,7 +1108,8 @@ const deTranslation = { loadPlaylist: 'Playlist laden', loading: 'Lade…', noPlaylists: 'Keine Playlists gefunden.', - load: 'Laden', + load: 'Warteschlange ersetzen & abspielen', + appendToQueue: 'An Warteschlange anhängen', delete: 'Löschen', deleteConfirm: 'Playlist "{{name}}" löschen?', clear: 'Leeren', @@ -1206,6 +1238,19 @@ const frTranslation = { title: 'Recherche', resultsFor: 'Résultats pour « {{query}} »', album: 'Album', + advanced: 'Recherche avancée', + advancedSearchTerm: 'Terme de recherche', + advancedSearchPlaceholder: 'Titre, album, artiste…', + advancedGenre: 'Genre', + advancedAllGenres: 'Tous les genres', + advancedYear: 'Année', + advancedYearFrom: 'de', + advancedYearTo: 'à', + advancedAll: 'Tous', + advancedSearch: 'Rechercher', + advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.', + advancedNoResults: 'Aucun résultat trouvé.', + advancedGenreNote: 'Les morceaux sont sélectionnés aléatoirement dans ce genre.', }, nowPlaying: { tooltip: 'Qui écoute ?', @@ -1309,6 +1354,7 @@ const frTranslation = { albums: 'Albums', songs: 'Morceaux', enqueueAll: 'Tout ajouter à la file', + playAll: 'Tout lire', removeSong: 'Retirer des favoris', }, randomAlbums: { @@ -1351,10 +1397,12 @@ const frTranslation = { artistClickHint: 'Cliquer pour bloquer cet artiste', blacklistToggle: 'Filtre par mot-clé', genreMixTitle: 'Mix par genre', - genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire', + genreMixDesc: 'Top 20 genres par nombre de morceaux — cliquer pour un mix aléatoire', genreMixLoadMore: 'Charger 10 de plus', genreMixNoGenres: 'Aucun genre trouvé sur le serveur.', + shuffleGenres: 'Afficher d\'autres genres', filterPanelTitle: 'Filtres', + filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.', genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.', }, albums: { @@ -1513,7 +1561,6 @@ const frTranslation = { aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic', aboutContributorsLabel: 'Contributeurs', - aboutContributors: 'jiezhuo — traduction chinoise · nullobject — correctifs seek & waveform', changelog: 'Journal des modifications', showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.', @@ -1654,7 +1701,8 @@ const frTranslation = { loadPlaylist: 'Charger une liste', loading: 'Chargement…', noPlaylists: 'Aucune liste trouvée.', - load: 'Charger', + load: 'Remplacer la file et lire', + appendToQueue: 'Ajouter à la file', delete: 'Supprimer', deleteConfirm: 'Supprimer la liste « {{name}} » ?', clear: 'Vider', @@ -1783,6 +1831,19 @@ const nlTranslation = { title: 'Zoeken', resultsFor: 'Resultaten voor "{{query}}"', album: 'Album', + advanced: 'Geavanceerd zoeken', + advancedSearchTerm: 'Zoekterm', + advancedSearchPlaceholder: 'Titel, album, artiest…', + advancedGenre: 'Genre', + advancedAllGenres: 'Alle genres', + advancedYear: 'Jaar', + advancedYearFrom: 'van', + advancedYearTo: 'tot', + advancedAll: 'Alle', + advancedSearch: 'Zoeken', + advancedEmpty: 'Voer een zoekterm in of selecteer een filter.', + advancedNoResults: 'Geen resultaten gevonden.', + advancedGenreNote: 'Nummers worden willekeurig uit dit genre geselecteerd.', }, nowPlaying: { tooltip: 'Wie luistert er?', @@ -1886,6 +1947,7 @@ const nlTranslation = { albums: 'Albums', songs: 'Nummers', enqueueAll: 'Alles aan wachtrij toevoegen', + playAll: 'Alles afspelen', removeSong: 'Verwijderen uit favorieten', }, randomAlbums: { @@ -1928,10 +1990,12 @@ const nlTranslation = { artistClickHint: 'Klik om deze artiest te blokkeren', blacklistToggle: 'Trefwoordfilter', genreMixTitle: 'Genremix', - genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix', + genreMixDesc: 'Top 20 genres op aantal nummers — klik voor een willekeurige mix', genreMixLoadMore: '10 meer laden', genreMixNoGenres: 'Geen genres gevonden op server.', + shuffleGenres: 'Andere genres tonen', filterPanelTitle: 'Filters', + filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.', genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.', }, albums: { @@ -2090,7 +2154,6 @@ const nlTranslation = { aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic', aboutContributorsLabel: 'Bijdragers', - aboutContributors: 'jiezhuo — Chinese vertaling · nullobject — seek & waveform-fixes', changelog: 'Wijzigingslog', showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.', @@ -2231,7 +2294,8 @@ const nlTranslation = { loadPlaylist: 'Afspeellijst laden', loading: 'Laden…', noPlaylists: 'Geen afspeellijsten gevonden.', - load: 'Laden', + load: 'Wachtrij vervangen & afspelen', + appendToQueue: 'Toevoegen aan wachtrij', delete: 'Verwijderen', deleteConfirm: 'Afspeellijst "{{name}}" verwijderen?', clear: 'Leegmaken', @@ -2360,6 +2424,19 @@ const zhTranslation = { title: '搜索', resultsFor: '"{{query}}" 的搜索结果', album: '专辑', + advanced: '高级搜索', + advancedSearchTerm: '搜索词', + advancedSearchPlaceholder: '标题、专辑、艺术家…', + advancedGenre: '流派', + advancedAllGenres: '所有流派', + advancedYear: '年份', + advancedYearFrom: '从', + advancedYearTo: '至', + advancedAll: '全部', + advancedSearch: '搜索', + advancedEmpty: '请输入搜索词或选择过滤器。', + advancedNoResults: '未找到结果。', + advancedGenreNote: '歌曲从该流派中随机选取。', }, nowPlaying: { tooltip: '谁正在收听?', @@ -2463,6 +2540,7 @@ const zhTranslation = { albums: '专辑', songs: '歌曲', enqueueAll: '全部加入队列', + playAll: '全部播放', removeSong: '从收藏中移除', }, randomAlbums: { @@ -2505,10 +2583,12 @@ const zhTranslation = { artistClickHint: '点击屏蔽此艺术家', blacklistToggle: '关键词过滤', genreMixTitle: '流派混音', - genreMixDesc: '选择一个流派以获取精选的随机混音', + genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音', genreMixLoadMore: '加载 10 首更多', genreMixNoGenres: '服务器上未找到流派。', + shuffleGenres: '显示其他流派', filterPanelTitle: '过滤器', + filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。', genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。', }, albums: { @@ -2667,7 +2747,6 @@ const zhTranslation = { aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建', aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发', aboutContributorsLabel: '贡献者', - aboutContributors: 'jiezhuo — 中文翻译 · nullobject — Seek & 波形修复', changelog: '更新日志', showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。', @@ -2808,7 +2887,8 @@ const zhTranslation = { loadPlaylist: '加载播放列表', loading: '加载中…', noPlaylists: '未找到播放列表。', - load: '加载', + load: '替换队列并播放', + appendToQueue: '追加到队列', delete: '删除', deleteConfirm: '删除播放列表 "{{name}}"?', clear: '清空', diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx new file mode 100644 index 00000000..a2352df9 --- /dev/null +++ b/src/pages/AdvancedSearch.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Play, SlidersHorizontal } from 'lucide-react'; +import { + search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, + SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, +} from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { useTranslation } from 'react-i18next'; +import AlbumRow from '../components/AlbumRow'; +import ArtistRow from '../components/ArtistRow'; +import CustomSelect from '../components/CustomSelect'; +import { useDragDrop } from '../contexts/DragDropContext'; + +type ResultType = 'all' | 'artists' | 'albums' | 'songs'; + +interface SearchOpts { + query: string; + genre: string; + yearFrom: string; + yearTo: string; + resultType: ResultType; +} + +interface Results { + artists: SubsonicArtist[]; + albums: SubsonicAlbum[]; + songs: SubsonicSong[]; +} + +export default function AdvancedSearch() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const navigate = useNavigate(); + const psyDrag = useDragDrop(); + const playTrack = usePlayerStore(s => s.playTrack); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + + const [query, setQuery] = useState(params.get('q') ?? ''); + const [genre, setGenre] = useState(''); + const [yearFrom, setYearFrom] = useState(''); + const [yearTo, setYearTo] = useState(''); + const [resultType, setResultType] = useState('all'); + const [genres, setGenres] = useState([]); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const [genreNote, setGenreNote] = useState(false); + + const runSearch = async (opts: SearchOpts) => { + setLoading(true); + setHasSearched(true); + setGenreNote(false); + const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts; + const from = yf ? parseInt(yf) : null; + const to = yt ? parseInt(yt) : null; + + let artists: SubsonicArtist[] = []; + let albums: SubsonicAlbum[] = []; + let songs: SubsonicSong[] = []; + + try { + if (q.trim()) { + const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 }); + artists = r.artists; + albums = r.albums; + songs = r.songs; + + if (g) { + albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase()); + songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); + } + if (from !== null) { + albums = albums.filter(a => !a.year || a.year >= from); + songs = songs.filter(s => !s.year || s.year >= from); + } + if (to !== null) { + albums = albums.filter(a => !a.year || a.year <= to); + songs = songs.filter(s => !s.year || s.year <= to); + } + } else if (g) { + const [albumRes, songRes] = await Promise.all([ + rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50), + rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g), + ]); + albums = albumRes as SubsonicAlbum[]; + songs = songRes as SubsonicSong[]; + if (from !== null) albums = albums.filter(a => !a.year || a.year >= from); + if (to !== null) albums = albums.filter(a => !a.year || a.year <= to); + if (songs.length > 0) setGenreNote(true); + } else if (from !== null || to !== null) { + const fromYear = from ?? 1900; + const toYear = to ?? new Date().getFullYear(); + albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear }); + } + + setResults({ + artists: rt === 'albums' || rt === 'songs' ? [] : artists, + albums: rt === 'artists' || rt === 'songs' ? [] : albums, + songs: rt === 'artists' || rt === 'albums' ? [] : songs, + }); + } catch { + setResults({ artists: [], albums: [], songs: [] }); + } + setLoading(false); + }; + + useEffect(() => { + getGenres().then(data => + setGenres(data.sort((a, b) => a.value.localeCompare(b.value))) + ).catch(() => {}); + const q = params.get('q') ?? ''; + if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); + }, []); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + runSearch({ query, genre, yearFrom, yearTo, resultType }); + }; + + const total = results + ? results.artists.length + results.albums.length + results.songs.length + : 0; + + const typeOptions: { id: ResultType; label: string }[] = [ + { id: 'all', label: t('search.advancedAll') }, + { id: 'artists', label: t('search.artists') }, + { id: 'albums', label: t('search.albums') }, + { id: 'songs', label: t('search.songs') }, + ]; + + const genreSelectOptions = [ + { value: '', label: t('search.advancedAllGenres') }, + ...genres.map(g => ({ value: g.value, label: g.value })), + ]; + + return ( +
+
+

+ + {t('search.advanced')} +

+
+ + {/* ── Filter panel ──────────────────────────────────────── */} +
+
+
+ + {/* Row 1: Search term */} +
+ + {t('search.advancedSearchTerm')} + + setQuery(e.target.value)} + placeholder={t('search.advancedSearchPlaceholder')} + style={{ flex: 1 }} + autoFocus + /> +
+ + {/* Row 2: Genre + Year */} +
+ + {t('search.advancedGenre')} + +
+ +
+ + + {t('search.advancedYear')} + + setYearFrom(e.target.value)} + placeholder={t('search.advancedYearFrom')} + style={{ width: 76 }} + /> + + setYearTo(e.target.value)} + placeholder={t('search.advancedYearTo')} + style={{ width: 76 }} + /> +
+ + {/* Row 3: Result type + Search button */} +
+
+ {typeOptions.map(opt => ( + + ))} +
+ +
+
+
+
+ + {/* ── Results ───────────────────────────────────────────── */} + {!hasSearched ? ( +
+ {t('search.advancedEmpty')} +
+ ) : loading ? ( +
+
+
+ ) : total === 0 ? ( +
{t('search.advancedNoResults')}
+ ) : ( +
+ + {results && results.artists.length > 0 && ( + + )} + + {results && results.albums.length > 0 && ( + + )} + + {results && results.songs.length > 0 && ( +
+

+ {t('search.songs')} ({results.songs.length}) + {genreNote && ( + + — {t('search.advancedGenreNote')} + + )} +

+
+
+ + {t('randomMix.trackTitle')} + {t('randomMix.trackArtist')} + {t('randomMix.trackAlbum')} + {t('randomMix.trackGenre')} + {t('randomMix.trackDuration')} +
+ {results.songs.map(song => { + const track = songToTrack(song); + return ( +
playTrack(track, results.songs.map(songToTrack))} + role="row" + onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} + onMouseDown={e => { + if (e.button !== 0) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); + } + }; + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > + +
+ {song.title} +
+
+ song.artistId && navigate(`/artist/${song.artistId}`)} + > + {song.artist} + +
+
+ navigate(`/album/${song.albumId}`)} + > + {song.album} + +
+
+ {song.genre ?? '—'} +
+ + {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} + +
+ ); + })} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 286e80a8..03a48e7b 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -3,7 +3,7 @@ import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; -import { ListPlus, X } from 'lucide-react'; +import { ListPlus, Play, X } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { unstar } from '../api/subsonic'; @@ -69,12 +69,22 @@ export default function Favorites() {

{t('favorites.songs')}

+ {(() => { - const isGenreLoading = selectedSuperGenre && !genreMixComplete; - const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0); + const isGenreLoading = selectedGenre && !genreMixComplete; + const isDisabled = loading || (selectedGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0); return ( - )) + <> + {displayedGenres.map(genre => ( + + ))} + {allAvailableGenres.length > 20 && ( + + )} + )}
- {/* Genre Mix tracklist (shown when a super-genre is selected) */} + {/* Genre Mix tracklist (shown when a genre is selected) */} {(genreMixLoading || genreMixSongs.length > 0) && (
- {SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix + {selectedGenre} Mix {genreMixLoading &&
}
@@ -381,7 +365,7 @@ const handlePlayAll = () => {
)} - {!selectedSuperGenre && (loading && songs.length === 0 ? ( + {!selectedGenre && (loading && songs.length === 0 ? (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c239726e..9f2ee78b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -4,7 +4,7 @@ import changelogRaw from '../../CHANGELOG.md?raw'; import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, - Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard + Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { open as openUrl } from '@tauri-apps/plugin-shell'; @@ -26,6 +26,32 @@ import Equalizer from '../components/Equalizer'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; +const CONTRIBUTORS = [ + { + github: 'jiezhuo', + since: '1.21', + contributions: [ + 'Chinese (Simplified) translation', + ], + }, + { + github: 'nullobject', + since: '1.22.0', + contributions: [ + 'Seek debounce & race condition fix (PR #7)', + 'Waveform seekbar stability on position update (PR #8)', + ], + }, + { + github: 'trbn1', + since: '1.22.0', + contributions: [ + 'Replay Gain metadata propagation (PR #9)', + 'songToTrack() — unified track construction across all sources', + ], + }, +] as const; + type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about'; function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit) => void; onCancel: () => void }) { @@ -118,6 +144,7 @@ export default function Settings() { const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); + const [contributorsOpen, setContributorsOpen] = useState(false); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } @@ -1017,9 +1044,51 @@ export default function Settings() { AI {t('settings.aboutAiCredit')}
-
- {t('settings.aboutContributorsLabel')} - {t('settings.aboutContributors')} +
+ + + {contributorsOpen && ( +
+ {CONTRIBUTORS.map(c => ( +
+ {c.github} +
+
+ + + v{c.since} + +
+
    + {c.contributions.map(item =>
  • {item}
  • )} +
+
+
+ ))} +
+ )}
diff --git a/src/styles/components.css b/src/styles/components.css index 28a9e800..de41065b 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -169,15 +169,14 @@ cursor: pointer; background: var(--bg-card); border-radius: var(--radius-lg); - border: 1px solid var(--border-subtle); overflow: hidden; - transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition: transform var(--transition-base), box-shadow var(--transition-base); } .album-card:hover { transform: translateY(-3px); - box-shadow: var(--shadow-md); - border-color: var(--border); + box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md); } .album-card-cover { @@ -363,6 +362,11 @@ gap: var(--space-4); } +.album-grid-wrap .album-card { + content-visibility: auto; + contain-intrinsic-size: 0 220px; +} + @media (min-width: 1024px) { .album-grid-wrap { @@ -498,13 +502,13 @@ .live-search-field { padding-left: 36px !important; - padding-right: 32px !important; + padding-right: 58px !important; border-radius: var(--radius-full) !important; } .live-search-clear { position: absolute; - right: 12px; + right: 8px; font-size: 18px; color: var(--text-muted); line-height: 1; @@ -515,6 +519,29 @@ color: var(--text-primary); } +.live-search-adv-btn { + position: absolute; + right: 30px; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + opacity: 0.55; + transition: opacity var(--transition-fast), color var(--transition-fast); + border-radius: var(--radius-sm); + padding: 0; +} + +.live-search-adv-btn:hover { + opacity: 1; + color: var(--accent); +} + .live-search-dropdown { position: absolute; top: calc(100% + 8px); @@ -4127,6 +4154,7 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); max-height: 220px; overflow-y: auto; + overscroll-behavior: contain; z-index: 500; } diff --git a/src/styles/theme.css b/src/styles/theme.css index a23de8e4..829e1341 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3306,13 +3306,13 @@ select.input.input:focus { .card { background: var(--bg-card); border-radius: var(--radius-lg); - border: 1px solid var(--border-subtle); overflow: hidden; + box-shadow: inset 0 0 0 1px var(--border-subtle); transition: transform var(--transition-base), box-shadow var(--transition-base); } .card:hover { transform: translateY(-2px); - box-shadow: var(--shadow-md); + box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md); } /* ─── Album Grid ─── */ @@ -11516,21 +11516,17 @@ input[type="range"]:hover::-webkit-slider-thumb { --logo-color-end: #C8960C; } -/* Sidebar — Nile-sky deep blue, subtle hieroglyph grid */ +/* Sidebar — Nile-sky deep blue, drifting cloud wisps */ [data-theme='powerslave'] .sidebar { background: #0A2035; border-right: none; background-image: - repeating-linear-gradient( - 0deg, - rgba(46, 123, 184, 0.08) 0px, transparent 1px, transparent 48px, - rgba(46, 123, 184, 0.08) 49px - ), - repeating-linear-gradient( - 90deg, - rgba(46, 123, 184, 0.05) 0px, transparent 1px, transparent 96px, - rgba(46, 123, 184, 0.05) 97px - ); + radial-gradient(ellipse 130% 30% at 50% 6%, rgba(140, 190, 235, 0.07) 0%, transparent 65%), + radial-gradient(ellipse 90% 25% at 18% 22%, rgba(100, 155, 205, 0.05) 0%, transparent 60%), + radial-gradient(ellipse 115% 28% at 78% 38%, rgba(120, 170, 225, 0.05) 0%, transparent 65%), + radial-gradient(ellipse 100% 26% at 38% 58%, rgba( 90, 145, 195, 0.04) 0%, transparent 60%), + radial-gradient(ellipse 120% 30% at 68% 75%, rgba(110, 165, 218, 0.05) 0%, transparent 65%), + radial-gradient(ellipse 85% 24% at 22% 92%, rgba(130, 180, 232, 0.04) 0%, transparent 60%); } [data-theme='powerslave'] .nav-link { color: rgba(200, 224, 248, 0.65); } @@ -11633,6 +11629,10 @@ input[type="range"]:hover::-webkit-slider-thumb { border: 1px solid #806006; } +[data-theme='powerslave'] .album-card-details-btn:hover { + background: linear-gradient(180deg, #E0A028 0%, #D48818 50%, #B07010 100%); +} + /* Badge — gold pill */ [data-theme='powerslave'] .badge, [data-theme='powerslave'] .album-detail-badge {