feat: v1.27.0 — In-App Auto-Update, Configurable Home, Icon Consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-02 02:14:57 +02:00
parent d8da511a8f
commit 55e7cb835b
30 changed files with 969 additions and 125 deletions
+25
View File
@@ -102,6 +102,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }} VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }} VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
releaseId: ${{ needs.create-release.outputs.release_id }} releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }} args: ${{ matrix.settings.args }}
@@ -146,3 +148,26 @@ jobs:
find src-tauri/target/release/bundle \ find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \ \( -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber | xargs gh release upload "app-v${VERSION}" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
+14
View File
@@ -5,6 +5,20 @@ 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.27.0] - 2026-04-02
### Added
- **In-App Auto-Update** *(requested by [@netherguy4](https://github.com/netherguy4))*: Psysonic now checks for new releases automatically on startup (3 s delay). On macOS and Windows a native install-and-relaunch flow is available directly in the app — no browser needed. On Linux, a download link to the GitHub release page is shown instead (AppImage is not built due to WebKitGTK incompatibility with Arch/Fedora). The updater uses Tauri's signed updater plugin with minisign signatures verified against a bundled public key. **Note: this release is the preparation — auto-update will be fully active for all subsequent releases once signed bundles are in place.**
- **Configurable Home Page**: Users can now choose which sections appear on the home page. A new "Home Page" block in Settings → Library lets you toggle each section individually (Featured, Recently Added, Discover, Discover Artists, Recently Played, Personal Favorites, Most Played) with a reset-to-default button. Hidden sections are skipped entirely.
- **Consistent icon language** *(requested by [@netherguy4](https://github.com/netherguy4))*: Favorites (local star/heart) now use a filled Heart icon everywhere — Player Bar, Album Detail, Artist Detail, Tracklist, Context Menu. Last.fm love always uses the Last.fm logo. Previously the two were mixed up in several places.
### Fixed
- **Album Detail hero background not loading**: The blurred album art background in Album Detail only appeared after a track change, never on first visit. Root cause: `buildCoverArtUrl` was called without `useMemo`, generating a new salt on every re-render — causing `useCachedUrl` to cancel and restart its fetch endlessly. Fixed by memoising both the URL and cache key on `album.coverArt`. Same fix applied to Hero and Playlist Detail backgrounds.
---
## [1.26.1] - 2026-04-01 ## [1.26.1] - 2026-04-01
### Fixed ### Fixed
+22 -2
View File
@@ -1,19 +1,21 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.21.0", "version": "1.26.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "psysonic", "name": "psysonic",
"version": "1.21.0", "version": "1.26.1",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"i18next": "^25.8.16", "i18next": "^25.8.16",
@@ -1493,6 +1495,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": { "node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@@ -1511,6 +1522,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-window-state": { "node_modules/@tauri-apps/plugin-window-state": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.26.1", "version": "1.27.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -15,8 +15,10 @@
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"i18next": "^25.8.16", "i18next": "^25.8.16",
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': `Psysonic_${VERSION}_aarch64.app.tar.gz`,
'darwin-x86_64': `Psysonic_${VERSION}_x64.app.tar.gz`,
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
};
const platforms = {};
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
// Pull release notes from GitHub
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
+217 -3
View File
@@ -69,6 +69,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@@ -922,6 +931,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -1221,6 +1241,17 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -2397,7 +2428,10 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"libc", "libc",
"plain",
"redox_syscall 0.7.3",
] ]
[[package]] [[package]]
@@ -2536,6 +2570,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2819,6 +2859,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2834,6 +2875,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2983,6 +3036,20 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -3032,7 +3099,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -3212,6 +3279,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.8.0"
@@ -3397,7 +3470,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.26.1" version = "1.27.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
@@ -3412,9 +3485,11 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
] ]
@@ -3551,6 +3626,15 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -3668,15 +3752,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3799,12 +3888,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -3814,6 +3916,33 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.9"
@@ -4268,7 +4397,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall", "redox_syscall 0.5.18",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -4650,6 +4779,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4843,6 +4983,16 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.3.5" version = "2.3.5"
@@ -4895,6 +5045,39 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-plugin-window-state" name = "tauri-plugin-window-state"
version = "2.4.1" version = "2.4.1"
@@ -5786,6 +5969,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -6576,6 +6768,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]] [[package]]
name = "xdg-home" name = "xdg-home"
version = "1.3.0" version = "1.3.0"
@@ -6822,6 +7024,18 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.26.1" version = "1.27.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
@@ -37,4 +37,6 @@ md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] } tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4" biquad = "0.4"
tauri-plugin-window-state = "2.4.1" tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
+3 -1
View File
@@ -30,6 +30,8 @@
"core:window:allow-set-fullscreen", "core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen", "core:window:allow-is-fullscreen",
"core:window:allow-create", "core:window:allow-create",
"core:webview:allow-create-webview-window" "core:webview:allow-create-webview-window",
"updater:default",
"process:allow-restart"
] ]
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}} {"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
+84
View File
@@ -6014,6 +6014,36 @@
"const": "global-shortcut:deny-unregister-all", "const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope." "markdownDescription": "Denies the unregister_all command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",
@@ -6254,6 +6284,60 @@
"const": "store:deny-values", "const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope." "markdownDescription": "Denies the values command without any pre-configured scope."
}, },
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{ {
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`", "description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string", "type": "string",
+84
View File
@@ -6014,6 +6014,36 @@
"const": "global-shortcut:deny-unregister-all", "const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope." "markdownDescription": "Denies the unregister_all command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",
@@ -6254,6 +6284,60 @@
"const": "store:deny-values", "const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope." "markdownDescription": "Denies the values command without any pre-configured scope."
}, },
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{ {
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`", "description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string", "type": "string",
+2
View File
@@ -296,6 +296,8 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(audio_engine) .manage(audio_engine)
.manage(ShortcutMap::default()) .manage(ShortcutMap::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
+9 -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.26.1", "version": "1.27.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -31,6 +31,14 @@
"csp": null "csp": null
} }
}, },
"plugins": {
"updater": {
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
]
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
+2
View File
@@ -44,6 +44,7 @@ import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail'; import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal'; import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal'; import ChangelogModal from './components/ChangelogModal';
import AppUpdater from './components/AppUpdater';
import { version } from '../package.json'; import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
@@ -276,6 +277,7 @@ function AppShell() {
<SongInfoModal /> <SongInfoModal />
<DownloadFolderModal /> <DownloadFolderModal />
<TooltipPortal /> <TooltipPortal />
<AppUpdater />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />} {changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div> </div>
); );
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic'; import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox'; import CoverLightbox from './CoverLightbox';
@@ -203,7 +203,7 @@ export default function AlbumHeader({
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }} style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
> >
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} /> <Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')} {t('albumDetail.favorite')}
</button> </button>
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Play, Star, ListPlus, X } from 'lucide-react'; import { Play, Heart, ListPlus, X } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -256,7 +256,7 @@ export default function AlbumTrackList({
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }} style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
> >
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} /> <Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button> </button>
</div> </div>
<StarRating <StarRating
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { open } from '@tauri-apps/plugin-shell';
import { RefreshCw, Download, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
// Semver comparison: returns true if `a` is newer than `b`
function isNewer(a: string, b: string): boolean {
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
}
return false;
}
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
export default function AppUpdater() {
const { t } = useTranslation();
const [state, setState] = useState<State>({ phase: 'idle' });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
if (cancelled) return;
try {
// Try Tauri native updater first (macOS + Windows)
const update = await check();
if (cancelled) return;
if (update) {
setState({ phase: 'available', version: update.version, update });
return;
}
} catch {
// Tauri updater unavailable or network error — fall through to GitHub check
}
// Fallback: GitHub API check (Linux / offline Tauri updater)
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok || cancelled) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, currentVersion)) {
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
}
} catch {
// No update check possible — stay idle
}
}, 3000);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
const handleInstall = async () => {
if (state.phase !== 'available' || !state.update) return;
const update = state.update;
const savedVersion = state.version;
let total = 0;
let downloaded = 0;
setState({ phase: 'downloading', pct: 0 });
try {
await update.downloadAndInstall((event: DownloadEvent) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
} else if (event.event === 'Finished') {
setState({ phase: 'installing' });
}
});
await relaunch();
} catch (e) {
console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update });
}
};
const handleDownload = () => {
open('https://github.com/Psychotoxical/psysonic/releases/latest');
};
const version = state.phase === 'available' ? state.version : '';
const canInstall = state.phase === 'available' && state.update !== null;
const isLinuxFallback = state.phase === 'available' && state.update === null;
return createPortal(
<div className="app-updater-toast">
<div className="app-updater-header">
<RefreshCw size={13} />
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
<X size={12} />
</button>
</div>
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
{state.phase === 'downloading' && (
<div className="app-updater-progress-wrap">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
</div>
<span className="app-updater-pct">{state.pct}%</span>
</div>
)}
{state.phase === 'installing' && (
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
)}
{state.phase === 'available' && (
<div className="app-updater-actions">
{canInstall && (
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
)}
</div>
)}
</div>,
document.body
);
}
+8 -7
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus, Info } from 'lucide-react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
@@ -304,7 +305,7 @@ export default function ContextMenu() {
setStarredOverride(song.id, !starred); setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song'); return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}> })}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div> </div>
{auth.lastfmSessionKey && (() => { {auth.lastfmSessionKey && (() => {
@@ -317,7 +318,7 @@ export default function ContextMenu() {
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey); else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}> })}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} /> <LastfmIcon size={14} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div> </div>
); );
@@ -346,7 +347,7 @@ export default function ContextMenu() {
setStarredOverride(album.id, !starred); setStarredOverride(album.id, !starred);
return starred ? unstar(album.id, 'album') : star(album.id, 'album'); return starred ? unstar(album.id, 'album') : star(album.id, 'album');
})}> })}>
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}> <div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
@@ -380,7 +381,7 @@ export default function ContextMenu() {
setStarredOverride(artist.id, !starred); setStarredOverride(artist.id, !starred);
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist'); return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
})}> })}>
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div> </div>
</> </>
@@ -421,7 +422,7 @@ export default function ContextMenu() {
setStarredOverride(song.id, !starred); setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song'); return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}> })}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div> </div>
{auth.lastfmSessionKey && (() => { {auth.lastfmSessionKey && (() => {
@@ -434,7 +435,7 @@ export default function ContextMenu() {
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey); else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}> })}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} /> <LastfmIcon size={14} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div> </div>
); );
+1 -1
View File
@@ -95,7 +95,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
// buildCoverArtUrl generates a new salt on every call — must be memoized. // buildCoverArtUrl generates a new salt on every call — must be memoized.
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]); const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]); const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey, false); const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Keep the last known good URL so HeroBg never receives '' during a cache-miss // Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in). // transition (which would cause the background to flash empty before fading in).
+5 -4
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, Star, MicVocal Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText'; import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -115,9 +116,9 @@ export default function PlayerBar() {
onClick={toggleStar} onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--ctp-yellow)' : 'var(--text-muted)', flexShrink: 0 }} style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
> >
<Star size={15} fill={isStarred ? 'var(--ctp-yellow)' : 'none'} /> <Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button> </button>
)} )}
{currentTrack && lastfmSessionKey && ( {currentTrack && lastfmSessionKey && (
@@ -128,7 +129,7 @@ export default function PlayerBar() {
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }} style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
> >
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} /> <LastfmIcon size={15} />
</button> </button>
)} )}
</div> </div>
+3 -66
View File
@@ -1,15 +1,13 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore'; import { useSidebarStore } from '../store/sidebarStore';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags, ListMusic PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
} from 'lucide-react'; } from 'lucide-react';
import PsysonicLogo from './PsysonicLogo'; import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo'; import PSmallLogo from './PSmallLogo';
@@ -30,42 +28,6 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' }, help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
}; };
function isNewer(latest: string, current: string): boolean {
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;
if (lMin !== cMin) return lMin > cMin;
return lPat > cPat;
}
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
const { t } = useTranslation();
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} data-tooltip={`${t('sidebar.updateAvailable')}: ${latestVersion}`} data-tooltip-pos="bottom">
<ArrowUpCircle size={20} />
</div>
);
}
return (
<div className="update-toast">
<div className="update-toast-header">
<ArrowUpCircle size={14} />
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<button
className="update-toast-link"
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
>
{t('sidebar.updateLink')}
</button>
</div>
);
}
export default function Sidebar({ export default function Sidebar({
isCollapsed = false, isCollapsed = false,
@@ -83,8 +45,6 @@ export default function Sidebar({
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
// Resolve ordered, visible items per section from store config // Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library') .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -93,28 +53,6 @@ export default function Sidebar({
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system') .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
.map(cfg => ALL_NAV_ITEMS[cfg.id]); .map(cfg => ALL_NAV_ITEMS[cfg.id]);
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.replace(/^v/i, ''));
}
} catch {
// network unavailable — silently skip
}
};
const initial = setTimeout(check, 1500);
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
}, []);
return ( return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}> <aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -178,8 +116,7 @@ export default function Sidebar({
)} )}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>} {visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />} {visibleSystem.map(item => (
{visibleSystem.map(item => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
+40
View File
@@ -28,6 +28,7 @@ const enTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
}, },
home: { home: {
hero: 'Featured',
starred: 'Personal Favorites', starred: 'Personal Favorites',
recent: 'Recently Added', recent: 'Recently Added',
mostPlayed: 'Most Played', mostPlayed: 'Most Played',
@@ -306,6 +307,12 @@ const enTranslation = {
bulkAddToPlaylist: 'Add to Playlist', bulkAddToPlaylist: 'Add to Playlist',
bulkRemoveFromPlaylist: 'Remove from Playlist', bulkRemoveFromPlaylist: 'Remove from Playlist',
bulkClear: 'Clear selection', bulkClear: 'Clear selection',
updaterAvailable: 'Update available',
updaterVersion: 'v{{version}} is ready',
updaterInstall: 'Install & Restart',
updaterDownloading: 'Downloading…',
updaterInstalling: 'Installing…',
updaterDownload: 'Download from GitHub',
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
@@ -400,6 +407,7 @@ const enTranslation = {
tabPlayback: 'Playback', tabPlayback: 'Playback',
tabLibrary: 'Library', tabLibrary: 'Library',
tabAppearance: 'Appearance', tabAppearance: 'Appearance',
homeCustomizerTitle: 'Home Page',
sidebarTitle: 'Sidebar', sidebarTitle: 'Sidebar',
sidebarReset: 'Reset to default', sidebarReset: 'Reset to default',
sidebarDrag: 'Drag to reorder', sidebarDrag: 'Drag to reorder',
@@ -687,6 +695,7 @@ const deTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
}, },
home: { home: {
hero: 'Featured',
starred: 'Persönliche Favoriten', starred: 'Persönliche Favoriten',
recent: 'Zuletzt hinzugefügt', recent: 'Zuletzt hinzugefügt',
mostPlayed: 'Meistgehört', mostPlayed: 'Meistgehört',
@@ -965,6 +974,12 @@ const deTranslation = {
bulkAddToPlaylist: 'Zur Playlist hinzufügen', bulkAddToPlaylist: 'Zur Playlist hinzufügen',
bulkRemoveFromPlaylist: 'Aus Playlist entfernen', bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
bulkClear: 'Auswahl aufheben', bulkClear: 'Auswahl aufheben',
updaterAvailable: 'Update verfügbar',
updaterVersion: 'v{{version}} ist bereit',
updaterInstall: 'Installieren & Neustart',
updaterDownloading: 'Wird geladen…',
updaterInstalling: 'Wird installiert…',
updaterDownload: 'Von GitHub herunterladen',
}, },
settings: { settings: {
title: 'Einstellungen', title: 'Einstellungen',
@@ -1059,6 +1074,7 @@ const deTranslation = {
tabPlayback: 'Wiedergabe', tabPlayback: 'Wiedergabe',
tabLibrary: 'Bibliothek', tabLibrary: 'Bibliothek',
tabAppearance: 'Darstellung', tabAppearance: 'Darstellung',
homeCustomizerTitle: 'Startseite',
sidebarTitle: 'Seitenleiste', sidebarTitle: 'Seitenleiste',
sidebarReset: 'Zurücksetzen', sidebarReset: 'Zurücksetzen',
sidebarDrag: 'Ziehen zum Umsortieren', sidebarDrag: 'Ziehen zum Umsortieren',
@@ -1346,6 +1362,7 @@ const frTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
}, },
home: { home: {
hero: 'En vedette',
starred: 'Favoris personnels', starred: 'Favoris personnels',
recent: 'Ajoutés récemment', recent: 'Ajoutés récemment',
mostPlayed: 'Les plus écoutés', mostPlayed: 'Les plus écoutés',
@@ -1624,6 +1641,12 @@ const frTranslation = {
bulkAddToPlaylist: 'Ajouter à la playlist', bulkAddToPlaylist: 'Ajouter à la playlist',
bulkRemoveFromPlaylist: 'Retirer de la playlist', bulkRemoveFromPlaylist: 'Retirer de la playlist',
bulkClear: 'Désélectionner', bulkClear: 'Désélectionner',
updaterAvailable: 'Mise à jour disponible',
updaterVersion: 'v{{version}} est prête',
updaterInstall: 'Installer & Redémarrer',
updaterDownloading: 'Téléchargement…',
updaterInstalling: 'Installation…',
updaterDownload: 'Télécharger depuis GitHub',
}, },
settings: { settings: {
title: 'Paramètres', title: 'Paramètres',
@@ -1718,6 +1741,7 @@ const frTranslation = {
tabPlayback: 'Lecture', tabPlayback: 'Lecture',
tabLibrary: 'Bibliothèque', tabLibrary: 'Bibliothèque',
tabAppearance: 'Apparence', tabAppearance: 'Apparence',
homeCustomizerTitle: 'Page d\'accueil',
sidebarTitle: 'Barre latérale', sidebarTitle: 'Barre latérale',
sidebarReset: 'Réinitialiser', sidebarReset: 'Réinitialiser',
sidebarDrag: 'Glisser pour réorganiser', sidebarDrag: 'Glisser pour réorganiser',
@@ -2005,6 +2029,7 @@ const nlTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
}, },
home: { home: {
hero: 'Uitgelicht',
starred: 'Persoonlijke favorieten', starred: 'Persoonlijke favorieten',
recent: 'Recent toegevoegd', recent: 'Recent toegevoegd',
mostPlayed: 'Meest gespeeld', mostPlayed: 'Meest gespeeld',
@@ -2283,6 +2308,12 @@ const nlTranslation = {
bulkAddToPlaylist: 'Toevoegen aan afspeellijst', bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst', bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
bulkClear: 'Selectie wissen', bulkClear: 'Selectie wissen',
updaterAvailable: 'Update beschikbaar',
updaterVersion: 'v{{version}} is klaar',
updaterInstall: 'Installeren & Herstarten',
updaterDownloading: 'Downloaden…',
updaterInstalling: 'Installeren…',
updaterDownload: 'Downloaden van GitHub',
}, },
settings: { settings: {
title: 'Instellingen', title: 'Instellingen',
@@ -2377,6 +2408,7 @@ const nlTranslation = {
tabPlayback: 'Afspelen', tabPlayback: 'Afspelen',
tabLibrary: 'Bibliotheek', tabLibrary: 'Bibliotheek',
tabAppearance: 'Weergave', tabAppearance: 'Weergave',
homeCustomizerTitle: 'Startpagina',
sidebarTitle: 'Zijbalk', sidebarTitle: 'Zijbalk',
sidebarReset: 'Standaard herstellen', sidebarReset: 'Standaard herstellen',
sidebarDrag: 'Slepen om te herordenen', sidebarDrag: 'Slepen om te herordenen',
@@ -2664,6 +2696,7 @@ const zhTranslation = {
playlists: '播放列表', playlists: '播放列表',
}, },
home: { home: {
hero: '精选',
starred: '个人收藏', starred: '个人收藏',
recent: '最近添加', recent: '最近添加',
mostPlayed: '最常播放', mostPlayed: '最常播放',
@@ -2942,6 +2975,12 @@ const zhTranslation = {
bulkAddToPlaylist: '添加到播放列表', bulkAddToPlaylist: '添加到播放列表',
bulkRemoveFromPlaylist: '从播放列表移除', bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择', bulkClear: '取消选择',
updaterAvailable: '有可用更新',
updaterVersion: 'v{{version}} 已就绪',
updaterInstall: '安装并重启',
updaterDownloading: '下载中…',
updaterInstalling: '安装中…',
updaterDownload: '从 GitHub 下载',
}, },
settings: { settings: {
title: '设置', title: '设置',
@@ -3036,6 +3075,7 @@ const zhTranslation = {
tabPlayback: '播放', tabPlayback: '播放',
tabLibrary: '音乐库', tabLibrary: '音乐库',
tabAppearance: '外观', tabAppearance: '外观',
homeCustomizerTitle: '首页',
sidebarTitle: '侧边栏', sidebarTitle: '侧边栏',
sidebarReset: '重置为默认', sidebarReset: '重置为默认',
sidebarDrag: '拖动以重新排序', sidebarDrag: '拖动以重新排序',
+8 -5
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
@@ -231,10 +231,13 @@ const handleEnqueueAll = () => {
deleteAlbum(album.album.id, serverId); deleteAlbum(album.album.id, serverId);
}; };
// Hooks must be called unconditionally — derive from nullable album state // Hooks must be called unconditionally — derive from nullable album state.
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : ''; // useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : ''; // memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false); // which cancels and restarts the useCachedUrl effect → background never resolves.
const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]);
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
if (loading) return <div className="loading-center"><div className="spinner" /></div>; if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>; if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
+2 -2
View File
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, Subson
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox'; import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -283,7 +283,7 @@ export default function ArtistDetail() {
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')} data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }} style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
> >
<Star size={14} fill={isStarred ? "currentColor" : "none"} /> <Heart size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')} {t('artistDetail.favorite')}
</button> </button>
</div> </div>
+33 -23
View File
@@ -4,8 +4,12 @@ import AlbumRow from '../components/AlbumRow';
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic'; import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useHomeStore } from '../store/homeStore';
export default function Home() { export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]); const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]); const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [random, setRandom] = useState<SubsonicAlbum[]>([]); const [random, setRandom] = useState<SubsonicAlbum[]>([]);
@@ -22,7 +26,7 @@ export default function Home() {
getAlbumList('random', 20).catch(() => []), getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []), getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []), getAlbumList('recent', 12).catch(() => []),
getArtists().catch(() => []), isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
]).then(([s, n, r, f, rp, artists]) => { ]).then(([s, n, r, f, rp, artists]) => {
setStarred(s); setStarred(s);
setRecent(n); setRecent(n);
@@ -60,7 +64,7 @@ export default function Home() {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<Hero albums={heroAlbums} /> {isVisible('hero') && <Hero albums={heroAlbums} />}
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}> <div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? ( {loading ? (
@@ -69,19 +73,23 @@ export default function Home() {
</div> </div>
) : ( ) : (
<> <>
<AlbumRow {isVisible('recent') && (
title={t('home.recent')} <AlbumRow
albums={recent} title={t('home.recent')}
onLoadMore={() => loadMore('newest', recent, setRecent)} albums={recent}
moreText={t('home.loadMore')} onLoadMore={() => loadMore('newest', recent, setRecent)}
/> moreText={t('home.loadMore')}
<AlbumRow />
title={t('home.discover')} )}
albums={random} {isVisible('discover') && (
onLoadMore={() => loadMore('random', random, setRandom)} <AlbumRow
moreText={t('home.discoverMore')} title={t('home.discover')}
/> albums={random}
{randomArtists.length > 0 && ( onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
/>
)}
{isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section"> <section className="album-row-section">
<div className="album-row-header"> <div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2> <h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
@@ -99,7 +107,7 @@ export default function Home() {
</div> </div>
</section> </section>
)} )}
{recentlyPlayed.length > 0 && ( {isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
<AlbumRow <AlbumRow
title={t('home.recentlyPlayed')} title={t('home.recentlyPlayed')}
albums={recentlyPlayed} albums={recentlyPlayed}
@@ -107,7 +115,7 @@ export default function Home() {
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
/> />
)} )}
{starred.length > 0 && ( {isVisible('starred') && starred.length > 0 && (
<AlbumRow <AlbumRow
title={t('home.starred')} title={t('home.starred')}
albums={starred} albums={starred}
@@ -115,12 +123,14 @@ export default function Home() {
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
/> />
)} )}
<AlbumRow {isVisible('mostPlayed') && (
title={t('home.mostPlayed')} <AlbumRow
albums={mostPlayed} title={t('home.mostPlayed')}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)} albums={mostPlayed}
moreText={t('home.loadMore')} onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
/> moreText={t('home.loadMore')}
/>
)}
</> </>
)} )}
</div> </div>
+1 -1
View File
@@ -136,7 +136,7 @@ export default function PlaylistDetail() {
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]); const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]); const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey, false); const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
// Song search // Song search
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
+47 -1
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw GripVertical, PanelLeft, RotateCcw, LayoutGrid
} from 'lucide-react'; } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell'; import { open as openUrl } from '@tauri-apps/plugin-shell';
@@ -21,6 +21,7 @@ import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore'; import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../components/Sidebar'; import { ALL_NAV_ITEMS } from '../components/Sidebar';
import { pingWithCredentials } from '../api/subsonic'; import { pingWithCredentials } from '../api/subsonic';
@@ -562,6 +563,8 @@ export default function Settings() {
</div> </div>
</div> </div>
</section> </section>
<HomeCustomizer />
</> </>
)} )}
@@ -1149,6 +1152,49 @@ function renderInline(text: string): React.ReactNode[] {
}); });
} }
function HomeCustomizer() {
const { t } = useTranslation();
const { sections, toggleSection, reset } = useHomeStore();
const SECTION_LABELS: Record<HomeSectionId, string> = {
hero: t('home.hero'),
recent: t('home.recent'),
discover: t('home.discover'),
discoverArtists: t('home.discoverArtists'),
recentlyPlayed: t('home.recentlyPlayed'),
starred: t('home.starred'),
mostPlayed: t('home.mostPlayed'),
};
return (
<section className="settings-section">
<div className="settings-section-header">
<LayoutGrid size={18} />
<h2>{t('settings.homeCustomizerTitle')}</h2>
<button
className="btn btn-ghost"
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
onClick={reset}
data-tooltip={t('settings.sidebarReset')}
>
<RotateCcw size={14} />
</button>
</div>
<div className="settings-card" style={{ padding: '4px 0' }}>
{sections.map(sec => (
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
<span className="toggle-track" />
</label>
</div>
))}
</div>
</section>
);
}
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) { function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({ const { onMouseDown } = useDragSource(() => ({
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
export interface HomeSectionConfig {
id: HomeSectionId;
visible: boolean;
}
export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
{ id: 'hero', visible: true },
{ id: 'recent', visible: true },
{ id: 'discover', visible: true },
{ id: 'discoverArtists', visible: true },
{ id: 'recentlyPlayed', visible: true },
{ id: 'starred', visible: true },
{ id: 'mostPlayed', visible: true },
];
interface HomeStore {
sections: HomeSectionConfig[];
toggleSection: (id: HomeSectionId) => void;
reset: () => void;
}
export const useHomeStore = create<HomeStore>()(
persist(
(set) => ({
sections: DEFAULT_HOME_SECTIONS,
toggleSection: (id) => set((s) => ({
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
})),
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
}),
{ name: 'psysonic_home' }
)
);
+99
View File
@@ -243,6 +243,105 @@
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* ── AppUpdater floating toast ── */
.app-updater-toast {
position: fixed;
bottom: calc(var(--player-height, 80px) + 12px);
left: 12px;
width: 210px;
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--bg-sidebar);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
display: flex;
flex-direction: column;
gap: 4px;
z-index: 9000;
animation: update-toast-in 0.35s ease both;
}
.app-updater-header {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.app-updater-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
flex: 1;
}
.app-updater-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 0;
display: flex;
align-items: center;
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.app-updater-dismiss:hover { opacity: 1; }
.app-updater-version {
font-size: 12px;
color: var(--text-secondary);
padding-left: 19px;
}
.app-updater-actions {
padding-left: 19px;
margin-top: 2px;
}
.app-updater-btn-primary {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 0;
opacity: 0.85;
transition: opacity var(--transition-fast);
}
.app-updater-btn-primary:hover { opacity: 1; }
.app-updater-progress-wrap {
display: flex;
align-items: center;
gap: var(--space-2);
padding-left: 19px;
margin-top: 2px;
}
.app-updater-progress-bar {
flex: 1;
height: 3px;
background: var(--bg-glass);
border-radius: 2px;
overflow: hidden;
}
.app-updater-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.15s ease;
}
.app-updater-pct {
font-size: 10px;
color: var(--text-muted);
min-width: 26px;
text-align: right;
}
.app-updater-status {
font-size: 11px;
color: var(--text-secondary);
padding-left: 19px;
font-style: italic;
}
.update-toast { .update-toast {
margin: 0 var(--space-1) var(--space-2); margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3); padding: var(--space-3) var(--space-3);