mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: v1.19.0 — NSIS Installer, Tray Removed, Storage Warning, Theme Polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ jobs:
|
|||||||
- platform: 'macos-latest'
|
- platform: 'macos-latest'
|
||||||
args: '--target x86_64-apple-darwin'
|
args: '--target x86_64-apple-darwin'
|
||||||
- platform: 'windows-latest'
|
- platform: 'windows-latest'
|
||||||
args: ''
|
args: '--bundles nsis'
|
||||||
runs-on: ${{ matrix.settings.platform }}
|
runs-on: ${{ matrix.settings.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|||||||
@@ -5,6 +5,34 @@ 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.19.0] - 2026-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Offline storage full warning**: When caching an album would exceed the configured storage limit, a dismissible warning banner appears directly on the album page with quick links to the Offline Library and Settings.
|
||||||
|
- **Offline Mode — Help section**: New section in the Help page covering cache setup, playback, and troubleshooting for offline use.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Windows installer — NSIS**: Switched from WiX/MSI to NSIS (`currentUser` install mode). Upgrades install in-place without requiring an uninstall first.
|
||||||
|
- **Tray icon — removed**: The system tray icon and its menu have been removed. Media keys and OS media controls (added in v1.17.0) make the tray redundant. The "Minimize to tray" setting has been removed accordingly. The app now always exits cleanly on window close.
|
||||||
|
- **Settings — cache label**: "Max. Image Cache Size" renamed to "Max. Storage Size" to reflect that the limit now covers both image cache and offline tracks.
|
||||||
|
- **Cover art — fade-in on load**: `CachedImage` now fades album art in (150 ms) instead of popping in abruptly. The image starts transparent and becomes visible once fully loaded, preventing layout flicker on slow connections.
|
||||||
|
- **Scrollbar auto-hide**: Scrollbar thumbs are hidden when content is not being scrolled and fade in on hover or while actively scrolling. System-style themes (W98, Muma Jukebox, Luna Teal, W3.1, DOS) retain always-visible scrollbars.
|
||||||
|
- **Help page — two-column layout**: Sections now flow in CSS columns (masonry layout) instead of a rigid two-column grid, making better use of available space.
|
||||||
|
- **Theme picker — preview corrections**: Updated colour swatches for T-800 (red accent, was cyan), WnAmp (yellow accent, was green), TetraStack (darker navy background), NightCity 2077 (darker blue-tinted background).
|
||||||
|
- **Theme overhaul — Grand Theft Audio, NightCity 2077**: Detailed per-element styling added — active queue item, hover states, track rows, artist/playlist rows, settings tabs, connection indicators, and more. Both themes are now fully consistent across all UI sections.
|
||||||
|
- **Theme refinements — Lambda 17, T-800, TetraStack, Muma Jukebox**: Targeted fixes for connection indicators, hover colours, active states, and contrast throughout.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **AlbumDetail — hero background flicker on hover**: Moving the mouse over songs in the track list caused the blurred hero background to reload on every hover. Moving `hoveredSongId` state into `AlbumTrackList` prevents the parent from re-rendering.
|
||||||
|
- **AlbumDetail — context menu loses row highlight**: Right-clicking a song caused the hover highlight to disappear. The row now stays highlighted while its context menu is open (`.context-active` pattern — consistent with Queue and Random Mix).
|
||||||
|
- **Muma Jukebox — hero readability**: The "Album" chip and meta info text below the artist name had insufficient contrast. Both are now legible.
|
||||||
|
- **Muma Jukebox — waveform colours**: Waveform now uses orange (played) and cyan (buffered) to match the theme's colour scheme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.18.0] - 2026-03-27
|
## [1.18.0] - 2026-03-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.8.0",
|
"version": "1.18.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.8.0",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
@@ -2302,9 +2302,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"@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-notification": "^2",
|
|
||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||||
pkgname=psysonic
|
pkgname=psysonic
|
||||||
pkgver=1.18.0
|
pkgver=1.19.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Generated
+13
-247
@@ -85,18 +85,6 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-broadcast"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener 5.4.1",
|
|
||||||
"event-listener-strategy",
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -210,24 +198,6 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-process"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
|
||||||
dependencies = [
|
|
||||||
"async-channel",
|
|
||||||
"async-io 2.6.0",
|
|
||||||
"async-lock 3.4.2",
|
|
||||||
"async-signal",
|
|
||||||
"async-task",
|
|
||||||
"blocking",
|
|
||||||
"cfg-if",
|
|
||||||
"event-listener 5.4.1",
|
|
||||||
"futures-lite 2.6.1",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1089,12 +1059,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "endi"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enumflags2"
|
name = "enumflags2"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -2445,18 +2409,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mac-notification-sys"
|
|
||||||
version = "0.6.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "26053f9919b5b032f327ab94d830f2465c4c88138e9df23c8fcd305060a9b28b"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"objc2",
|
|
||||||
"objc2-foundation",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -2704,20 +2656,6 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "notify-rust"
|
|
||||||
version = "4.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
|
||||||
dependencies = [
|
|
||||||
"futures-lite 2.6.1",
|
|
||||||
"log",
|
|
||||||
"mac-notification-sys",
|
|
||||||
"serde",
|
|
||||||
"tauri-winrt-notification",
|
|
||||||
"zbus 5.14.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2845,7 +2783,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3247,7 +3184,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3424,7 +3361,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.18.0"
|
version = "1.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"md5",
|
"md5",
|
||||||
@@ -3438,7 +3375,6 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-notification",
|
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
@@ -3451,15 +3387,6 @@ version = "0.1.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.37.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -3515,16 +3442,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.9.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
|
||||||
dependencies = [
|
|
||||||
"rand_chacha 0.9.0",
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3545,16 +3462,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3573,15 +3480,6 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.9.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -4380,8 +4278,8 @@ dependencies = [
|
|||||||
"pollster",
|
"pollster",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"windows 0.44.0",
|
"windows 0.44.0",
|
||||||
"zbus 3.15.2",
|
"zbus",
|
||||||
"zvariant 3.15.2",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4907,25 +4805,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-plugin-notification"
|
|
||||||
version = "2.3.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"notify-rust",
|
|
||||||
"rand 0.9.2",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_repr",
|
|
||||||
"tauri",
|
|
||||||
"tauri-plugin",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"time",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-shell"
|
name = "tauri-plugin-shell"
|
||||||
version = "2.3.5"
|
version = "2.3.5"
|
||||||
@@ -5078,18 +4957,6 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-winrt-notification"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
|
||||||
dependencies = [
|
|
||||||
"quick-xml 0.37.5",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"windows 0.61.3",
|
|
||||||
"windows-version",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.26.0"
|
version = "3.26.0"
|
||||||
@@ -6701,12 +6568,12 @@ version = "3.15.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6"
|
checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast 0.5.1",
|
"async-broadcast",
|
||||||
"async-executor",
|
"async-executor",
|
||||||
"async-fs",
|
"async-fs",
|
||||||
"async-io 1.13.0",
|
"async-io 1.13.0",
|
||||||
"async-lock 2.8.0",
|
"async-lock 2.8.0",
|
||||||
"async-process 1.8.1",
|
"async-process",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-task",
|
"async-task",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6731,44 +6598,9 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"winapi",
|
"winapi",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros 3.15.2",
|
"zbus_macros",
|
||||||
"zbus_names 2.6.1",
|
"zbus_names",
|
||||||
"zvariant 3.15.2",
|
"zvariant",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus"
|
|
||||||
version = "5.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
|
|
||||||
dependencies = [
|
|
||||||
"async-broadcast 0.7.2",
|
|
||||||
"async-executor",
|
|
||||||
"async-io 2.6.0",
|
|
||||||
"async-lock 3.4.2",
|
|
||||||
"async-process 2.5.0",
|
|
||||||
"async-recursion",
|
|
||||||
"async-task",
|
|
||||||
"async-trait",
|
|
||||||
"blocking",
|
|
||||||
"enumflags2",
|
|
||||||
"event-listener 5.4.1",
|
|
||||||
"futures-core",
|
|
||||||
"futures-lite 2.6.1",
|
|
||||||
"hex",
|
|
||||||
"libc",
|
|
||||||
"ordered-stream",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"serde",
|
|
||||||
"serde_repr",
|
|
||||||
"tracing",
|
|
||||||
"uds_windows",
|
|
||||||
"uuid",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
"winnow 0.7.15",
|
|
||||||
"zbus_macros 5.14.0",
|
|
||||||
"zbus_names 4.3.1",
|
|
||||||
"zvariant 5.10.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6782,22 +6614,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
"zvariant_utils 1.0.1",
|
"zvariant_utils",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_macros"
|
|
||||||
version = "5.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate 3.5.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
"zbus_names 4.3.1",
|
|
||||||
"zvariant 5.10.0",
|
|
||||||
"zvariant_utils 3.3.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6808,18 +6625,7 @@ checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant 3.15.2",
|
"zvariant",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_names"
|
|
||||||
version = "4.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"winnow 0.7.15",
|
|
||||||
"zvariant 5.10.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6919,21 +6725,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant_derive 3.15.2",
|
"zvariant_derive",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant"
|
|
||||||
version = "5.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
|
|
||||||
dependencies = [
|
|
||||||
"endi",
|
|
||||||
"enumflags2",
|
|
||||||
"serde",
|
|
||||||
"winnow 0.7.15",
|
|
||||||
"zvariant_derive 5.10.0",
|
|
||||||
"zvariant_utils 3.3.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6946,20 +6738,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
"zvariant_utils 1.0.1",
|
"zvariant_utils",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_derive"
|
|
||||||
version = "5.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate 3.5.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
"zvariant_utils 3.3.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6972,16 +6751,3 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_utils"
|
|
||||||
version = "3.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde",
|
|
||||||
"syn 2.0.117",
|
|
||||||
"winnow 0.7.15",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.18.0"
|
version = "1.19.0"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
@@ -23,7 +23,6 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-notification = "2"
|
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||||
"notification:default",
|
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"store:default",
|
"store:default",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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://**"}]},"notification:default","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"],"platforms":["linux","macOS","windows"]}}
|
||||||
@@ -6014,204 +6014,6 @@
|
|||||||
"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\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:default",
|
|
||||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the batch command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-batch",
|
|
||||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the cancel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-cancel",
|
|
||||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-check-permissions",
|
|
||||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-create-channel",
|
|
||||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-delete-channel",
|
|
||||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-get-active",
|
|
||||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-get-pending",
|
|
||||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-is-permission-granted",
|
|
||||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-list-channels",
|
|
||||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the notify command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-notify",
|
|
||||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-permission-state",
|
|
||||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-register-action-types",
|
|
||||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-register-listener",
|
|
||||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-remove-active",
|
|
||||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-request-permission",
|
|
||||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the show command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-show",
|
|
||||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the batch command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-batch",
|
|
||||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the cancel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-cancel",
|
|
||||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-check-permissions",
|
|
||||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-create-channel",
|
|
||||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-delete-channel",
|
|
||||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-get-active",
|
|
||||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-get-pending",
|
|
||||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-is-permission-granted",
|
|
||||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-list-channels",
|
|
||||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the notify command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-notify",
|
|
||||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-permission-state",
|
|
||||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-register-action-types",
|
|
||||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-register-listener",
|
|
||||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-remove-active",
|
|
||||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-request-permission",
|
|
||||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the show command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-show",
|
|
||||||
"markdownDescription": "Denies the show 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",
|
||||||
|
|||||||
@@ -6014,204 +6014,6 @@
|
|||||||
"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\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:default",
|
|
||||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the batch command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-batch",
|
|
||||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the cancel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-cancel",
|
|
||||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-check-permissions",
|
|
||||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-create-channel",
|
|
||||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-delete-channel",
|
|
||||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-get-active",
|
|
||||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-get-pending",
|
|
||||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-is-permission-granted",
|
|
||||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-list-channels",
|
|
||||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the notify command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-notify",
|
|
||||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-permission-state",
|
|
||||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-register-action-types",
|
|
||||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-register-listener",
|
|
||||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-remove-active",
|
|
||||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-request-permission",
|
|
||||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the show command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:allow-show",
|
|
||||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the batch command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-batch",
|
|
||||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the cancel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-cancel",
|
|
||||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-check-permissions",
|
|
||||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-create-channel",
|
|
||||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-delete-channel",
|
|
||||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-get-active",
|
|
||||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-get-pending",
|
|
||||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-is-permission-granted",
|
|
||||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-list-channels",
|
|
||||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the notify command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-notify",
|
|
||||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-permission-state",
|
|
||||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-register-action-types",
|
|
||||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-register-listener",
|
|
||||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-remove-active",
|
|
||||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-request-permission",
|
|
||||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the show command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "notification:deny-show",
|
|
||||||
"markdownDescription": "Denies the show 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",
|
||||||
|
|||||||
+2
-64
@@ -6,11 +6,7 @@ mod audio;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{Emitter, Manager};
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
|
||||||
Emitter, Manager,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||||
@@ -298,61 +294,12 @@ pub fn run() {
|
|||||||
.manage(ShortcutMap::default())
|
.manage(ShortcutMap::default())
|
||||||
.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_notification::init())
|
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Build tray menu
|
|
||||||
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
|
|
||||||
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
|
|
||||||
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
|
|
||||||
let show = MenuItemBuilder::with_id("show", "Show Psysonic").build(app)?;
|
|
||||||
let quit = MenuItemBuilder::with_id("quit", "Exit").build(app)?;
|
|
||||||
|
|
||||||
let menu = MenuBuilder::new(app)
|
|
||||||
.item(&play_pause)
|
|
||||||
.item(&next)
|
|
||||||
.item(&separator)
|
|
||||||
.item(&show)
|
|
||||||
.item(&quit)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let _tray = TrayIconBuilder::new()
|
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
|
||||||
.menu(&menu)
|
|
||||||
.tooltip("Psysonic")
|
|
||||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
|
||||||
"play_pause" => {
|
|
||||||
let _ = app.emit("tray:play-pause", ());
|
|
||||||
}
|
|
||||||
"next" => {
|
|
||||||
let _ = app.emit("tray:next", ());
|
|
||||||
}
|
|
||||||
"show" => {
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"quit" => {
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
})
|
|
||||||
.on_tray_icon_event(|_tray, event| {
|
|
||||||
if let TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
button_state: MouseButtonState::Up,
|
|
||||||
..
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
// Left click shows app (handled in JS side via tray event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build(app)?;
|
|
||||||
|
|
||||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||||
{
|
{
|
||||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||||
@@ -445,15 +392,6 @@ pub fn run() {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
|
||||||
// Only intercept close for the main window (hide to tray).
|
|
||||||
// Browser popup windows (browser_*) close normally.
|
|
||||||
if window.label() == "main" {
|
|
||||||
let _ = window.emit("window:close-requested", ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet,
|
greet,
|
||||||
exit_app,
|
exit_app,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
"minimumSystemVersion": "10.15"
|
"minimumSystemVersion": "10.15"
|
||||||
},
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
"wix": {
|
"nsis": {
|
||||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
"installMode": "currentUser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-13
@@ -36,6 +36,7 @@ import LastfmIndicator from './components/LastfmIndicator';
|
|||||||
import OfflineOverlay from './components/OfflineOverlay';
|
import OfflineOverlay from './components/OfflineOverlay';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
import OfflineBanner from './components/OfflineBanner';
|
||||||
import OfflineLibrary from './pages/OfflineLibrary';
|
import OfflineLibrary from './pages/OfflineLibrary';
|
||||||
|
import ExportPickerModal from './components/ExportPickerModal';
|
||||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { useOfflineStore } from './store/offlineStore';
|
import { useOfflineStore } from './store/offlineStore';
|
||||||
@@ -230,12 +231,11 @@ function AppShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tray / media key event handler
|
// Media key event handler
|
||||||
function TauriEventBridge() {
|
function TauriEventBridge() {
|
||||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||||
const next = usePlayerStore(s => s.next);
|
const next = usePlayerStore(s => s.next);
|
||||||
const previous = usePlayerStore(s => s.previous);
|
const previous = usePlayerStore(s => s.previous);
|
||||||
const { minimizeToTray } = useAuthStore();
|
|
||||||
|
|
||||||
// Configurable keybindings
|
// Configurable keybindings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -295,8 +295,6 @@ function TauriEventBridge() {
|
|||||||
['media:prev', () => previous()],
|
['media:prev', () => previous()],
|
||||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||||
['tray:play-pause', () => togglePlay()],
|
|
||||||
['tray:next', () => next()],
|
|
||||||
];
|
];
|
||||||
for (const [event, handler] of handlers) {
|
for (const [event, handler] of handlers) {
|
||||||
const u = await listen(event, handler);
|
const u = await listen(event, handler);
|
||||||
@@ -326,15 +324,10 @@ function TauriEventBridge() {
|
|||||||
unlisten.push(u);
|
unlisten.push(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
// Close → exit app
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const u = await win.onCloseRequested(async (event) => {
|
const u = await win.onCloseRequested(async () => {
|
||||||
if (minimizeToTray) {
|
await invoke('exit_app');
|
||||||
event.preventDefault();
|
|
||||||
await win.hide();
|
|
||||||
} else {
|
|
||||||
await invoke('exit_app');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (cancelled) { u(); return; }
|
if (cancelled) { u(); return; }
|
||||||
unlisten.push(u);
|
unlisten.push(u);
|
||||||
@@ -342,7 +335,7 @@ function TauriEventBridge() {
|
|||||||
|
|
||||||
setup();
|
setup();
|
||||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||||
}, [togglePlay, next, previous, minimizeToTray]);
|
}, [togglePlay, next, previous]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -350,6 +343,7 @@ function TauriEventBridge() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const theme = useThemeStore(s => s.theme);
|
const theme = useThemeStore(s => s.theme);
|
||||||
const font = useFontStore(s => s.font);
|
const font = useFontStore(s => s.font);
|
||||||
|
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
@@ -367,6 +361,67 @@ export default function App() {
|
|||||||
useGlobalShortcutsStore.getState().registerAll();
|
useGlobalShortcutsStore.getState().registerAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || !e.altKey || e.code !== 'KeyN') return;
|
||||||
|
e.preventDefault();
|
||||||
|
setExportPickerOpen(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExport = async (since: number) => {
|
||||||
|
setExportPickerOpen(false);
|
||||||
|
const showToast = (text: string) => {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position:fixed; bottom:100px; left:50%; transform:translateX(-50%);
|
||||||
|
background:#24273a; color:#cad3f5; border:1px solid #363a4f;
|
||||||
|
padding:10px 20px; border-radius:10px; font-size:14px;
|
||||||
|
z-index:999999; pointer-events:none;
|
||||||
|
box-shadow:0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
white-space:nowrap;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 4000);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
|
||||||
|
const result = await exportNewAlbumsImage(since);
|
||||||
|
if (result) {
|
||||||
|
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
|
||||||
|
showToast(`📸 ${result.count} Alben exportiert${files}`);
|
||||||
|
} else {
|
||||||
|
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
|
||||||
|
console.error('[easter egg] export failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
|
||||||
|
const onScroll = (e: Event) => {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
el.classList.add('is-scrolling');
|
||||||
|
const existing = timers.get(el);
|
||||||
|
if (existing !== undefined) clearTimeout(existing);
|
||||||
|
timers.set(el, setTimeout(() => {
|
||||||
|
el.classList.remove('is-scrolling');
|
||||||
|
timers.delete(el);
|
||||||
|
}, 800));
|
||||||
|
};
|
||||||
|
document.addEventListener('scroll', onScroll, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('scroll', onScroll, true);
|
||||||
|
timers.forEach(t => clearTimeout(t));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<TauriEventBridge />
|
<TauriEventBridge />
|
||||||
@@ -381,6 +436,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface SubsonicAlbum {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
starred?: string;
|
starred?: string;
|
||||||
recordLabel?: string;
|
recordLabel?: string;
|
||||||
|
created?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsonicSong {
|
export interface SubsonicSong {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Play, Star } from 'lucide-react';
|
import { Play, Star } from 'lucide-react';
|
||||||
import { SubsonicSong } from '../api/subsonic';
|
import { SubsonicSong } from '../api/subsonic';
|
||||||
import { Track } from '../store/playerStore';
|
import { Track, usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
@@ -45,8 +45,6 @@ interface AlbumTrackListProps {
|
|||||||
hasVariousArtists: boolean;
|
hasVariousArtists: boolean;
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
hoveredSongId: string | null;
|
|
||||||
setHoveredSongId: (id: string | null) => void;
|
|
||||||
ratings: Record<string, number>;
|
ratings: Record<string, number>;
|
||||||
starredSongs: Set<string>;
|
starredSongs: Set<string>;
|
||||||
onPlaySong: (song: SubsonicSong) => void;
|
onPlaySong: (song: SubsonicSong) => void;
|
||||||
@@ -60,8 +58,6 @@ export default function AlbumTrackList({
|
|||||||
hasVariousArtists,
|
hasVariousArtists,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
hoveredSongId,
|
|
||||||
setHoveredSongId,
|
|
||||||
ratings,
|
ratings,
|
||||||
starredSongs,
|
starredSongs,
|
||||||
onPlaySong,
|
onPlaySong,
|
||||||
@@ -70,6 +66,12 @@ export default function AlbumTrackList({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: AlbumTrackListProps) {
|
}: AlbumTrackListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||||
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||||
|
}, [contextMenuOpen]);
|
||||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||||
|
|
||||||
const discs = new Map<number, SubsonicSong[]>();
|
const discs = new Map<number, SubsonicSong[]>();
|
||||||
@@ -112,12 +114,13 @@ export default function AlbumTrackList({
|
|||||||
{discs.get(discNum)!.map((song, i) => (
|
{discs.get(discNum)!.map((song, i) => (
|
||||||
<div
|
<div
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
|
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||||
onMouseLeave={() => setHoveredSongId(null)}
|
onMouseLeave={() => setHoveredSongId(null)}
|
||||||
onDoubleClick={() => onPlaySong(song)}
|
onDoubleClick={() => onPlaySong(song)}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setContextMenuSongId(song.id);
|
||||||
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
||||||
}}
|
}}
|
||||||
role="row"
|
role="row"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { getCachedUrl } from '../utils/imageCache';
|
import { getCachedUrl } from '../utils/imageCache';
|
||||||
|
|
||||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
@@ -17,7 +17,22 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
|||||||
return resolved || fetchUrl;
|
return resolved || fetchUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
|
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||||
return <img src={resolvedSrc} {...props} />;
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const prevSrc = useRef('');
|
||||||
|
|
||||||
|
if (resolvedSrc !== prevSrc.current) {
|
||||||
|
prevSrc.current = resolvedSrc;
|
||||||
|
setLoaded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={resolvedSrc}
|
||||||
|
style={{ ...style, opacity: loaded ? 1 : 0, transition: loaded ? 'opacity 0.15s ease' : 'none' }}
|
||||||
|
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onConfirm: (since: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportPickerModal({ onConfirm, onClose }: Props) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const [date, setDate] = useState(today);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const since = new Date(date + 'T00:00:00').getTime();
|
||||||
|
onConfirm(since);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 99998,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '28px 32px',
|
||||||
|
width: '340px',
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.5)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
Alben exportieren
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: '4px', display: 'flex' }}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: '0 0 16px', fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
Alle Alben exportieren, die seit diesem Datum hinzugekommen sind:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={today}
|
||||||
|
onChange={e => {
|
||||||
|
setDate(e.target.value);
|
||||||
|
e.target.blur();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
background: 'var(--bg-app)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
outline: 'none',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
|
||||||
|
<button className="btn btn-surface" onClick={onClose} style={{ flex: 1 }}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!date}
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
Exportieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
||||||
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
||||||
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
|
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
|
||||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
|
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
|
||||||
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||||
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||||
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
||||||
@@ -33,7 +33,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
|
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
|
||||||
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||||
{ id: 't-800', label: 'T-800', bg: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
|
{ id: 't-800', label: 'T-800', bg: '#140e0e', card: '#1a0a0a', accent: '#ff2000' },
|
||||||
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
|
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
|
||||||
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
||||||
],
|
],
|
||||||
@@ -92,7 +92,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
||||||
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
|
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
|
||||||
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#d4cc46' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ function drawWaveform(
|
|||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
|
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||||
|
|
||||||
if (!heights) {
|
if (!heights) {
|
||||||
ctx.globalAlpha = 0.3;
|
ctx.globalAlpha = 0.3;
|
||||||
|
|||||||
+44
-20
@@ -100,6 +100,9 @@ const enTranslation = {
|
|||||||
offlineCached: 'Available offline',
|
offlineCached: 'Available offline',
|
||||||
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Remove offline cache',
|
removeOffline: 'Remove offline cache',
|
||||||
|
offlineStorageFull: 'Offline storage full (limit: {{mb}} MB). Free up space by deleting an album from your Offline Library, or increase the limit in Settings.',
|
||||||
|
offlineStorageGoToLibrary: 'Offline Library',
|
||||||
|
offlineStorageGoToSettings: 'Settings',
|
||||||
favoriteAdd: 'Add to Favorites',
|
favoriteAdd: 'Add to Favorites',
|
||||||
favoriteRemove: 'Remove from Favorites',
|
favoriteRemove: 'Remove from Favorites',
|
||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
@@ -332,9 +335,7 @@ const enTranslation = {
|
|||||||
scrobbleEnabled: 'Scrobbling enabled',
|
scrobbleEnabled: 'Scrobbling enabled',
|
||||||
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
||||||
behavior: 'App Behavior',
|
behavior: 'App Behavior',
|
||||||
trayTitle: 'Minimize to Tray',
|
cacheTitle: 'Max. Storage Size',
|
||||||
trayDesc: 'Minimize app to the system tray on close (X)',
|
|
||||||
cacheTitle: 'Max. Image Cache Size',
|
|
||||||
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
||||||
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
|
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
|
||||||
cacheClearBtn: 'Clear Cache',
|
cacheClearBtn: 'Clear Cache',
|
||||||
@@ -433,8 +434,6 @@ const enTranslation = {
|
|||||||
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'How do I change the language?',
|
q13: 'How do I change the language?',
|
||||||
a13: 'Settings → Language. English, German, French, and Dutch are supported.',
|
a13: 'Settings → Language. English, German, French, and Dutch are supported.',
|
||||||
q14: 'What does "Minimize to Tray" do?',
|
|
||||||
a14: 'When enabled, clicking the × button hides Psysonic to the system tray instead of closing it. Music keeps playing. Right-click the tray icon for controls or to quit.',
|
|
||||||
q15: 'How do I set a download folder?',
|
q15: 'How do I set a download folder?',
|
||||||
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
|
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -474,6 +473,13 @@ const enTranslation = {
|
|||||||
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
|
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
|
||||||
q32: 'Can I change the font?',
|
q32: 'Can I change the font?',
|
||||||
a32: 'Yes. Settings → Font lets you choose from 10 fonts including IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime, and others. The selected font applies to the entire UI.',
|
a32: 'Yes. Settings → Font lets you choose from 10 fonts including IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime, and others. The selected font applies to the entire UI.',
|
||||||
|
s8: 'Offline Mode',
|
||||||
|
q34: 'What is Offline Mode?',
|
||||||
|
a34: 'Offline Mode (Beta) lets you cache albums to your device so you can listen without an active server connection. Cached tracks are stored locally and played back directly from disk — no network request is made during playback.',
|
||||||
|
q35: 'How do I cache an album for offline use?',
|
||||||
|
a35: 'Open any album and click the download icon in the album header. Psysonic downloads all tracks in the background. Progress is shown on the button. Once cached, the icon turns green. You can view and remove cached albums in the Offline Library page (sidebar).',
|
||||||
|
q36: 'How much storage can offline caching use?',
|
||||||
|
a36: 'You can set a maximum cache size in Settings → Library. When the limit is reached, a warning banner appears on the album page. You can delete individual albums from the Offline Library to free up space.',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Queue',
|
title: 'Queue',
|
||||||
@@ -661,6 +667,9 @@ const deTranslation = {
|
|||||||
offlineCached: 'Offline verfügbar',
|
offlineCached: 'Offline verfügbar',
|
||||||
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Offline-Cache löschen',
|
removeOffline: 'Offline-Cache löschen',
|
||||||
|
offlineStorageFull: 'Offline-Speicher voll (Limit: {{mb}} MB). Lösche ein Album aus der Offline-Bibliothek oder erhöhe das Limit in den Einstellungen.',
|
||||||
|
offlineStorageGoToSettings: 'Einstellungen',
|
||||||
|
offlineStorageGoToLibrary: 'Offline-Bibliothek',
|
||||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||||
favoriteRemove: 'Aus Favoriten entfernen',
|
favoriteRemove: 'Aus Favoriten entfernen',
|
||||||
favorite: 'Als Favorit',
|
favorite: 'Als Favorit',
|
||||||
@@ -893,9 +902,7 @@ const deTranslation = {
|
|||||||
scrobbleEnabled: 'Scrobbling aktiviert',
|
scrobbleEnabled: 'Scrobbling aktiviert',
|
||||||
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
||||||
behavior: 'App-Verhalten',
|
behavior: 'App-Verhalten',
|
||||||
trayTitle: 'In Tray minimieren',
|
cacheTitle: 'Max. Speichergröße',
|
||||||
trayDesc: 'App beim Schließen (X) in den System-Tray minimieren',
|
|
||||||
cacheTitle: 'Max. Bild-Cache-Größe',
|
|
||||||
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
||||||
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
|
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
|
||||||
cacheClearBtn: 'Cache leeren',
|
cacheClearBtn: 'Cache leeren',
|
||||||
@@ -994,8 +1001,6 @@ const deTranslation = {
|
|||||||
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Wie ändere ich die Sprache?',
|
q13: 'Wie ändere ich die Sprache?',
|
||||||
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.',
|
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.',
|
||||||
q14: 'Was bewirkt "In Tray minimieren"?',
|
|
||||||
a14: 'Wenn aktiviert, versteckt sich Psysonic beim Klick auf × im System-Tray statt sich zu schließen. Die Musik läuft weiter. Rechtsklick auf das Tray-Icon zeigt Steueroptionen und "Beenden".',
|
|
||||||
q15: 'Wie lege ich einen Download-Ordner fest?',
|
q15: 'Wie lege ich einen Download-Ordner fest?',
|
||||||
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
|
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -1035,6 +1040,13 @@ const deTranslation = {
|
|||||||
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
|
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
|
||||||
q32: 'Kann ich die Schriftart ändern?',
|
q32: 'Kann ich die Schriftart ändern?',
|
||||||
a32: 'Ja. Einstellungen → Schriftart bietet 10 Schriften zur Auswahl, darunter IBM Plex Mono, Fira Code, JetBrains Mono und Courier Prime. Die gewählte Schrift gilt für die gesamte Oberfläche.',
|
a32: 'Ja. Einstellungen → Schriftart bietet 10 Schriften zur Auswahl, darunter IBM Plex Mono, Fira Code, JetBrains Mono und Courier Prime. Die gewählte Schrift gilt für die gesamte Oberfläche.',
|
||||||
|
s8: 'Offline-Modus',
|
||||||
|
q34: 'Was ist der Offline-Modus?',
|
||||||
|
a34: 'Der Offline-Modus (Beta) ermöglicht das Zwischenspeichern von Alben auf dem Gerät, sodass die Wiedergabe ohne aktive Serververbindung möglich ist. Gecachte Tracks werden lokal gespeichert und direkt von der Festplatte abgespielt — ohne Netzwerkzugriff.',
|
||||||
|
q35: 'Wie speichere ich ein Album für die Offline-Nutzung?',
|
||||||
|
a35: 'Album öffnen und auf das Download-Symbol im Album-Header klicken. Psysonic lädt alle Tracks im Hintergrund herunter. Der Fortschritt wird am Button angezeigt. Nach dem Download wird das Symbol grün. Gecachte Alben können in der Offline-Bibliothek (Seitenleiste) verwaltet und gelöscht werden.',
|
||||||
|
q36: 'Wie viel Speicherplatz darf der Cache belegen?',
|
||||||
|
a36: 'In Einstellungen → Bibliothek kann eine maximale Cache-Größe festgelegt werden. Wird das Limit erreicht, erscheint ein Warnbanner auf der Albumseite. Einzelne Alben können in der Offline-Bibliothek gelöscht werden, um Speicherplatz freizugeben.',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Warteschlange',
|
title: 'Warteschlange',
|
||||||
@@ -1222,6 +1234,9 @@ const frTranslation = {
|
|||||||
offlineCached: 'Disponible hors ligne',
|
offlineCached: 'Disponible hors ligne',
|
||||||
offlineDownloading: 'Mise en cache… ({{n}}/{{total}})',
|
offlineDownloading: 'Mise en cache… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Supprimer le cache hors ligne',
|
removeOffline: 'Supprimer le cache hors ligne',
|
||||||
|
offlineStorageFull: 'Stockage hors ligne plein (limite : {{mb}} Mo). Supprimez un album de votre bibliothèque hors ligne ou augmentez la limite dans les paramètres.',
|
||||||
|
offlineStorageGoToSettings: 'Paramètres',
|
||||||
|
offlineStorageGoToLibrary: 'Bibliothèque hors ligne',
|
||||||
favoriteAdd: 'Ajouter aux favoris',
|
favoriteAdd: 'Ajouter aux favoris',
|
||||||
favoriteRemove: 'Retirer des favoris',
|
favoriteRemove: 'Retirer des favoris',
|
||||||
favorite: 'Favori',
|
favorite: 'Favori',
|
||||||
@@ -1454,9 +1469,7 @@ const frTranslation = {
|
|||||||
scrobbleEnabled: 'Scrobbling activé',
|
scrobbleEnabled: 'Scrobbling activé',
|
||||||
scrobbleDesc: 'Envoyer les morceaux à Last.fm après 50% d\'écoute',
|
scrobbleDesc: 'Envoyer les morceaux à Last.fm après 50% d\'écoute',
|
||||||
behavior: 'Comportement de l\'application',
|
behavior: 'Comportement de l\'application',
|
||||||
trayTitle: 'Réduire dans la barre système',
|
cacheTitle: 'Taille max. du stockage',
|
||||||
trayDesc: 'Réduire l\'application dans la barre système à la fermeture (X)',
|
|
||||||
cacheTitle: 'Taille max. du cache images',
|
|
||||||
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
||||||
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
|
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
|
||||||
cacheClearBtn: 'Vider le cache',
|
cacheClearBtn: 'Vider le cache',
|
||||||
@@ -1555,8 +1568,6 @@ const frTranslation = {
|
|||||||
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Comment changer la langue ?',
|
q13: 'Comment changer la langue ?',
|
||||||
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.',
|
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.',
|
||||||
q14: 'Que fait « Réduire dans la barre système » ?',
|
|
||||||
a14: 'Quand activé, cliquer sur × masque Psysonic dans la barre système au lieu de le fermer. La musique continue de jouer. Cliquez droit sur l\'icône pour accéder aux contrôles ou quitter.',
|
|
||||||
q15: 'Comment définir un dossier de téléchargement ?',
|
q15: 'Comment définir un dossier de téléchargement ?',
|
||||||
a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.',
|
a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -1596,6 +1607,13 @@ const frTranslation = {
|
|||||||
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
|
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
|
||||||
q32: 'Puis-je changer la police ?',
|
q32: 'Puis-je changer la police ?',
|
||||||
a32: 'Oui. Paramètres → Police propose 10 polices dont IBM Plex Mono, Fira Code, JetBrains Mono et Courier Prime. La police choisie s\'applique à toute l\'interface.',
|
a32: 'Oui. Paramètres → Police propose 10 polices dont IBM Plex Mono, Fira Code, JetBrains Mono et Courier Prime. La police choisie s\'applique à toute l\'interface.',
|
||||||
|
s8: 'Mode hors ligne',
|
||||||
|
q34: 'Qu\'est-ce que le mode hors ligne ?',
|
||||||
|
a34: 'Le mode hors ligne (Bêta) permet de mettre des albums en cache sur l\'appareil afin d\'écouter de la musique sans connexion serveur active. Les pistes mises en cache sont stockées localement et lues directement depuis le disque — sans requête réseau.',
|
||||||
|
q35: 'Comment mettre un album en cache pour une utilisation hors ligne ?',
|
||||||
|
a35: 'Ouvrez un album et cliquez sur l\'icône de téléchargement dans l\'en-tête de l\'album. Psysonic télécharge toutes les pistes en arrière-plan. La progression est affichée sur le bouton. Une fois en cache, l\'icône devient verte. Les albums mis en cache peuvent être consultés et supprimés dans la page Bibliothèque hors ligne (barre latérale).',
|
||||||
|
q36: 'Quelle quantité de stockage le cache hors ligne peut-il utiliser ?',
|
||||||
|
a36: 'Vous pouvez définir une taille de cache maximale dans Paramètres → Bibliothèque. Lorsque la limite est atteinte, une bannière d\'avertissement s\'affiche sur la page de l\'album. Vous pouvez supprimer des albums individuels dans la Bibliothèque hors ligne pour libérer de l\'espace.',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'File d\'attente',
|
title: 'File d\'attente',
|
||||||
@@ -1783,6 +1801,9 @@ const nlTranslation = {
|
|||||||
offlineCached: 'Offline beschikbaar',
|
offlineCached: 'Offline beschikbaar',
|
||||||
offlineDownloading: 'Wordt gecached… ({{n}}/{{total}})',
|
offlineDownloading: 'Wordt gecached… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Offline cache verwijderen',
|
removeOffline: 'Offline cache verwijderen',
|
||||||
|
offlineStorageFull: 'Offline opslag vol (limiet: {{mb}} MB). Verwijder een album uit je offline bibliotheek of verhoog de limiet in de instellingen.',
|
||||||
|
offlineStorageGoToSettings: 'Instellingen',
|
||||||
|
offlineStorageGoToLibrary: 'Offline bibliotheek',
|
||||||
favoriteAdd: 'Aan favorieten toevoegen',
|
favoriteAdd: 'Aan favorieten toevoegen',
|
||||||
favoriteRemove: 'Uit favorieten verwijderen',
|
favoriteRemove: 'Uit favorieten verwijderen',
|
||||||
favorite: 'Favoriet',
|
favorite: 'Favoriet',
|
||||||
@@ -2015,9 +2036,7 @@ const nlTranslation = {
|
|||||||
scrobbleEnabled: 'Scrobbling ingeschakeld',
|
scrobbleEnabled: 'Scrobbling ingeschakeld',
|
||||||
scrobbleDesc: 'Nummers naar Last.fm sturen na 50% afspeeltijd',
|
scrobbleDesc: 'Nummers naar Last.fm sturen na 50% afspeeltijd',
|
||||||
behavior: 'App-gedrag',
|
behavior: 'App-gedrag',
|
||||||
trayTitle: 'Minimaliseren naar systeemvak',
|
cacheTitle: 'Max. opslaggrootte',
|
||||||
trayDesc: 'App naar het systeemvak minimaliseren bij sluiten (X)',
|
|
||||||
cacheTitle: 'Max. afbeeldingscachegrootte',
|
|
||||||
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
||||||
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
|
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
|
||||||
cacheClearBtn: 'Cache wissen',
|
cacheClearBtn: 'Cache wissen',
|
||||||
@@ -2116,8 +2135,6 @@ const nlTranslation = {
|
|||||||
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Hoe verander ik de taal?',
|
q13: 'Hoe verander ik de taal?',
|
||||||
a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.',
|
a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.',
|
||||||
q14: 'Wat doet "Minimaliseren naar systeemvak"?',
|
|
||||||
a14: 'Wanneer ingeschakeld verbergt het klikken op × Psysonic naar het systeemvak in plaats van het te sluiten. Muziek blijft spelen. Klik rechts op het pictogram voor besturingen of om af te sluiten.',
|
|
||||||
q15: 'Hoe stel ik een downloadmap in?',
|
q15: 'Hoe stel ik een downloadmap in?',
|
||||||
a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.',
|
a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -2157,6 +2174,13 @@ const nlTranslation = {
|
|||||||
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
|
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
|
||||||
q32: 'Kan ik het lettertype wijzigen?',
|
q32: 'Kan ik het lettertype wijzigen?',
|
||||||
a32: 'Ja. Instellingen → Lettertype biedt 10 lettertypen waaronder IBM Plex Mono, Fira Code, JetBrains Mono en Courier Prime. Het gekozen lettertype geldt voor de gehele interface.',
|
a32: 'Ja. Instellingen → Lettertype biedt 10 lettertypen waaronder IBM Plex Mono, Fira Code, JetBrains Mono en Courier Prime. Het gekozen lettertype geldt voor de gehele interface.',
|
||||||
|
s8: 'Offlinemodus',
|
||||||
|
q34: 'Wat is de Offlinemodus?',
|
||||||
|
a34: 'De Offlinemodus (Beta) laat je albums op het apparaat opslaan zodat je zonder actieve serververbinding kunt luisteren. Gecachte nummers worden lokaal opgeslagen en direct van schijf afgespeeld — zonder netwerkverzoek.',
|
||||||
|
q35: 'Hoe sla ik een album op voor offline gebruik?',
|
||||||
|
a35: 'Open een album en klik op het downloadpictogram in de albumkoptekst. Psysonic downloadt alle nummers op de achtergrond. De voortgang wordt op de knop weergegeven. Na het cachen wordt het pictogram groen. Gecachte albums zijn te bekijken en verwijderen via de Offline bibliotheek (zijbalk).',
|
||||||
|
q36: 'Hoeveel opslagruimte mag de offlinecache gebruiken?',
|
||||||
|
a36: 'Je kunt een maximale cachegrootte instellen via Instellingen → Bibliotheek. Als de limiet is bereikt, verschijnt er een waarschuwingsbanner op de albumpagina. Je kunt afzonderlijke albums verwijderen in de Offline bibliotheek om ruimte vrij te maken.',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Wachtrij',
|
title: 'Wachtrij',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
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';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -43,7 +44,7 @@ export default function AlbumDetail() {
|
|||||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
const [offlineStorageFull, setOfflineStorageFull] = useState(false);
|
||||||
|
|
||||||
const { downloadAlbum, deleteAlbum } = useOfflineStore();
|
const { downloadAlbum, deleteAlbum } = useOfflineStore();
|
||||||
const offlineTracks = useOfflineStore(s => s.tracks);
|
const offlineTracks = useOfflineStore(s => s.tracks);
|
||||||
@@ -207,10 +208,21 @@ export default function AlbumDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCacheOffline = () => {
|
const handleCacheOffline = useCallback(async () => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
|
const maxBytes = auth.maxCacheMb * 1024 * 1024;
|
||||||
|
try {
|
||||||
|
const usedBytes = await invoke<number>('get_offline_cache_size');
|
||||||
|
if (usedBytes >= maxBytes) {
|
||||||
|
setOfflineStorageFull(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't check, proceed anyway
|
||||||
|
}
|
||||||
|
setOfflineStorageFull(false);
|
||||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
|
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
|
||||||
};
|
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
|
||||||
|
|
||||||
const handleRemoveOffline = () => {
|
const handleRemoveOffline = () => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
@@ -251,14 +263,24 @@ export default function AlbumDetail() {
|
|||||||
onCacheOffline={handleCacheOffline}
|
onCacheOffline={handleCacheOffline}
|
||||||
onRemoveOffline={handleRemoveOffline}
|
onRemoveOffline={handleRemoveOffline}
|
||||||
/>
|
/>
|
||||||
|
{offlineStorageFull && (
|
||||||
|
<div className="offline-storage-full-banner" role="alert">
|
||||||
|
<span>{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}</span>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/offline')}>
|
||||||
|
{t('albumDetail.offlineStorageGoToLibrary')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/settings', { state: { tab: 'library' } })}>
|
||||||
|
{t('albumDetail.offlineStorageGoToSettings')}
|
||||||
|
</button>
|
||||||
|
<button className="offline-storage-full-dismiss" onClick={() => setOfflineStorageFull(false)} aria-label="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlbumTrackList
|
<AlbumTrackList
|
||||||
songs={songs}
|
songs={songs}
|
||||||
hasVariousArtists={hasVariousArtists}
|
hasVariousArtists={hasVariousArtists}
|
||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
hoveredSongId={hoveredSongId}
|
|
||||||
setHoveredSongId={setHoveredSongId}
|
|
||||||
ratings={ratings}
|
ratings={ratings}
|
||||||
starredSongs={new Set([
|
starredSongs={new Set([
|
||||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||||
|
|||||||
+12
-4
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
|
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle, WifiOff } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface FaqItem { q: string; a: string; }
|
interface FaqItem { q: string; a: string; }
|
||||||
@@ -65,7 +65,6 @@ export default function Help() {
|
|||||||
items: [
|
items: [
|
||||||
{ q: t('help.q12'), a: t('help.a12') },
|
{ q: t('help.q12'), a: t('help.a12') },
|
||||||
{ q: t('help.q13'), a: t('help.a13') },
|
{ q: t('help.q13'), a: t('help.a13') },
|
||||||
{ q: t('help.q14'), a: t('help.a14') },
|
|
||||||
{ q: t('help.q15'), a: t('help.a15') },
|
{ q: t('help.q15'), a: t('help.a15') },
|
||||||
{ q: t('help.q31'), a: t('help.a31') },
|
{ q: t('help.q31'), a: t('help.a31') },
|
||||||
{ q: t('help.q32'), a: t('help.a32') },
|
{ q: t('help.q32'), a: t('help.a32') },
|
||||||
@@ -88,6 +87,15 @@ export default function Help() {
|
|||||||
{ q: t('help.q28'), a: t('help.a28') },
|
{ q: t('help.q28'), a: t('help.a28') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <WifiOff size={18} />,
|
||||||
|
title: t('help.s8'),
|
||||||
|
items: [
|
||||||
|
{ q: t('help.q34'), a: t('help.a34') },
|
||||||
|
{ q: t('help.q35'), a: t('help.a35') },
|
||||||
|
{ q: t('help.q36'), a: t('help.a36') },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <Wrench size={18} />,
|
icon: <Wrench size={18} />,
|
||||||
title: t('help.s6'),
|
title: t('help.s6'),
|
||||||
@@ -104,9 +112,9 @@ export default function Help() {
|
|||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
|
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
|
<div style={{ columns: 2, columnGap: '1.25rem' }}>
|
||||||
{sections.map((section, si) => (
|
{sections.map((section, si) => (
|
||||||
<section key={si} className="settings-section">
|
<section key={si} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
{section.icon}
|
{section.icon}
|
||||||
<h2>{section.title}</h2>
|
<h2>{section.title}</h2>
|
||||||
|
|||||||
@@ -944,19 +944,6 @@ export default function Settings() {
|
|||||||
<h2>{t('settings.behavior')}</h2>
|
<h2>{t('settings.behavior')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
|
|
||||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider" />
|
|
||||||
|
|
||||||
<div className="settings-toggle-row">
|
<div className="settings-toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface AuthState {
|
|||||||
lastfmUsername: string;
|
lastfmUsername: string;
|
||||||
|
|
||||||
// Settings (global)
|
// Settings (global)
|
||||||
minimizeToTray: boolean;
|
|
||||||
scrobblingEnabled: boolean;
|
scrobblingEnabled: boolean;
|
||||||
maxCacheMb: number;
|
maxCacheMb: number;
|
||||||
downloadFolder: string;
|
downloadFolder: string;
|
||||||
@@ -51,7 +50,6 @@ interface AuthState {
|
|||||||
connectLastfm: (sessionKey: string, username: string) => void;
|
connectLastfm: (sessionKey: string, username: string) => void;
|
||||||
disconnectLastfm: () => void;
|
disconnectLastfm: () => void;
|
||||||
setLastfmSessionError: (v: boolean) => void;
|
setLastfmSessionError: (v: boolean) => void;
|
||||||
setMinimizeToTray: (v: boolean) => void;
|
|
||||||
setScrobblingEnabled: (v: boolean) => void;
|
setScrobblingEnabled: (v: boolean) => void;
|
||||||
setMaxCacheMb: (v: number) => void;
|
setMaxCacheMb: (v: number) => void;
|
||||||
setDownloadFolder: (v: string) => void;
|
setDownloadFolder: (v: string) => void;
|
||||||
@@ -82,7 +80,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
lastfmApiSecret: '',
|
lastfmApiSecret: '',
|
||||||
lastfmSessionKey: '',
|
lastfmSessionKey: '',
|
||||||
lastfmUsername: '',
|
lastfmUsername: '',
|
||||||
minimizeToTray: false,
|
|
||||||
scrobblingEnabled: true,
|
scrobblingEnabled: true,
|
||||||
maxCacheMb: 500,
|
maxCacheMb: 500,
|
||||||
downloadFolder: '',
|
downloadFolder: '',
|
||||||
@@ -139,7 +136,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
||||||
|
|
||||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
|
||||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||||
|
|||||||
@@ -413,6 +413,40 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Offline Storage Full Banner ─── */
|
||||||
|
.offline-storage-full-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
background: color-mix(in srgb, var(--color-danger, #e53935) 12%, var(--bg-card));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger, #e53935) 40%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-storage-full-banner span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-storage-full-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-storage-full-dismiss:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Player Bar ─── */
|
/* ─── Player Bar ─── */
|
||||||
.player-bar {
|
.player-bar {
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
|
|||||||
+1113
-341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
|||||||
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import { downloadDir, join } from '@tauri-apps/api/path';
|
||||||
|
import { getAlbumList, buildCoverArtUrl } from '../api/subsonic';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import type { SubsonicAlbum } from '../api/subsonic';
|
||||||
|
|
||||||
|
// Catppuccin Macchiato palette
|
||||||
|
const M = {
|
||||||
|
crust: '#181926',
|
||||||
|
mantle: '#1e2030',
|
||||||
|
base: '#24273a',
|
||||||
|
surface0: '#363a4f',
|
||||||
|
surface1: '#494d64',
|
||||||
|
surface2: '#5b6078',
|
||||||
|
text: '#cad3f5',
|
||||||
|
subtext1: '#b8c0e0',
|
||||||
|
subtext0: '#a5adcb',
|
||||||
|
mauve: '#c6a0f6',
|
||||||
|
lavender: '#b7bdf8',
|
||||||
|
overlay2: '#939ab7',
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = 1080;
|
||||||
|
const PAD = 56;
|
||||||
|
const COVER_SIZE = 52;
|
||||||
|
const ROW_H = 72;
|
||||||
|
const COVER_PAD = (ROW_H - COVER_SIZE) / 2;
|
||||||
|
const TEXT_X = PAD + COVER_SIZE + 18;
|
||||||
|
const TEXT_W = W - TEXT_X - PAD;
|
||||||
|
const HEADER_H = 260;
|
||||||
|
const FOOTER_H = 72;
|
||||||
|
const MAX_PER_PAGE = 20;
|
||||||
|
|
||||||
|
function clampText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||||||
|
if (ctx.measureText(text).width <= maxW) return text;
|
||||||
|
let t = text;
|
||||||
|
while (ctx.measureText(t + '…').width > maxW && t.length > 0) t = t.slice(0, -1);
|
||||||
|
return t + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImage(url: string): Promise<ImageBitmap | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await createImageBitmap(await res.blob());
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogo(): Promise<HTMLImageElement | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/psysonic-inapp-logo.svg');
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(
|
||||||
|
albums: SubsonicAlbum[],
|
||||||
|
covers: (ImageBitmap | null)[],
|
||||||
|
logo: HTMLImageElement | null,
|
||||||
|
now: Date,
|
||||||
|
totalCount: number,
|
||||||
|
pageNum: number,
|
||||||
|
totalPages: number,
|
||||||
|
globalOffset: number,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const H = HEADER_H + albums.length * ROW_H + FOOTER_H;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = M.base;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_H);
|
||||||
|
headerGrad.addColorStop(0, M.mantle);
|
||||||
|
headerGrad.addColorStop(1, M.base);
|
||||||
|
ctx.fillStyle = headerGrad;
|
||||||
|
ctx.fillRect(0, 0, W, HEADER_H);
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
const LOGO_H = 52;
|
||||||
|
const logoY = 44;
|
||||||
|
if (logo) {
|
||||||
|
const logoW = logo.naturalWidth && logo.naturalHeight
|
||||||
|
? Math.round(LOGO_H * (logo.naturalWidth / logo.naturalHeight))
|
||||||
|
: LOGO_H * 4;
|
||||||
|
ctx.drawImage(logo, W / 2 - logoW / 2, logoY, logoW, LOGO_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = '700 42px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = M.text;
|
||||||
|
ctx.fillText('Die neuesten Alben', W / 2, logoY + LOGO_H + 52);
|
||||||
|
|
||||||
|
// Date + page indicator
|
||||||
|
const dateStr = now.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||||
|
const pageStr = totalPages > 1 ? ` · Teil ${pageNum} / ${totalPages}` : '';
|
||||||
|
ctx.font = '400 18px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = M.subtext0;
|
||||||
|
ctx.fillText(dateStr + pageStr, W / 2, logoY + LOGO_H + 82);
|
||||||
|
|
||||||
|
// Count badge (total, only on first page)
|
||||||
|
if (pageNum === 1) {
|
||||||
|
const badgeText = `${totalCount} ${totalCount !== 1 ? 'Alben' : 'Album'}`;
|
||||||
|
ctx.font = '600 13px system-ui, sans-serif';
|
||||||
|
const badgeW = ctx.measureText(badgeText).width + 24;
|
||||||
|
const badgeX = W / 2 - badgeW / 2;
|
||||||
|
const badgeY = logoY + LOGO_H + 102;
|
||||||
|
roundRect(ctx, badgeX, badgeY, badgeW, 24, 12);
|
||||||
|
ctx.fillStyle = M.surface0;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = M.mauve;
|
||||||
|
ctx.fillText(badgeText, W / 2, badgeY + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
ctx.strokeStyle = M.surface1;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD, HEADER_H - 16);
|
||||||
|
ctx.lineTo(W - PAD, HEADER_H - 16);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Album rows
|
||||||
|
for (let i = 0; i < albums.length; i++) {
|
||||||
|
const album = albums[i];
|
||||||
|
const cover = covers[i];
|
||||||
|
const rowY = HEADER_H + i * ROW_H;
|
||||||
|
const coverY = rowY + COVER_PAD;
|
||||||
|
const globalIdx = globalOffset + i;
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(54,58,79,0.35)';
|
||||||
|
ctx.fillRect(0, rowY, W, ROW_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.font = '500 13px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = M.surface2;
|
||||||
|
ctx.fillText(String(globalIdx + 1), PAD - 12, coverY + COVER_SIZE / 2 + 5);
|
||||||
|
|
||||||
|
// Cover
|
||||||
|
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
|
||||||
|
ctx.fillStyle = M.surface0;
|
||||||
|
ctx.fill();
|
||||||
|
if (cover) {
|
||||||
|
ctx.save();
|
||||||
|
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(cover, PAD, coverY, COVER_SIZE, COVER_SIZE);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
ctx.font = '28px system-ui';
|
||||||
|
ctx.fillStyle = M.surface2;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('♪', PAD + COVER_SIZE / 2, coverY + COVER_SIZE / 2 + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text row
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.font = '600 17px system-ui, sans-serif';
|
||||||
|
const lineY = coverY + COVER_SIZE / 2 + 6;
|
||||||
|
const sep = ' — ';
|
||||||
|
|
||||||
|
const artistClamp = clampText(ctx, album.artist, TEXT_W * 0.42);
|
||||||
|
const artistW = ctx.measureText(artistClamp).width;
|
||||||
|
const sepW = ctx.measureText(sep).width;
|
||||||
|
const remaining = TEXT_W - artistW - sepW;
|
||||||
|
const albumClamp = clampText(ctx, album.name, remaining * 0.65);
|
||||||
|
const albumW = ctx.measureText(albumClamp).width;
|
||||||
|
|
||||||
|
ctx.fillStyle = M.mauve;
|
||||||
|
ctx.fillText(artistClamp, TEXT_X, lineY);
|
||||||
|
ctx.fillStyle = M.overlay2;
|
||||||
|
ctx.fillText(sep, TEXT_X + artistW, lineY);
|
||||||
|
ctx.fillStyle = M.text;
|
||||||
|
ctx.fillText(albumClamp, TEXT_X + artistW + sepW, lineY);
|
||||||
|
|
||||||
|
if (album.year || album.genre) {
|
||||||
|
ctx.font = '400 15px system-ui, sans-serif';
|
||||||
|
let cx = TEXT_X + artistW + sepW + albumW;
|
||||||
|
if (album.year) {
|
||||||
|
ctx.fillStyle = M.subtext0;
|
||||||
|
const yearPart = ` (${album.year})`;
|
||||||
|
ctx.fillText(yearPart, cx, lineY);
|
||||||
|
cx += ctx.measureText(yearPart).width;
|
||||||
|
}
|
||||||
|
if (album.genre) {
|
||||||
|
ctx.fillStyle = M.subtext0;
|
||||||
|
const dashPart = ' — ';
|
||||||
|
ctx.fillText(dashPart, cx, lineY);
|
||||||
|
cx += ctx.measureText(dashPart).width;
|
||||||
|
ctx.fillStyle = M.lavender;
|
||||||
|
ctx.fillText(clampText(ctx, album.genre, TEXT_X + TEXT_W - cx), cx, lineY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < albums.length - 1) {
|
||||||
|
ctx.strokeStyle = M.surface0;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD, rowY + ROW_H);
|
||||||
|
ctx.lineTo(W - PAD, rowY + ROW_H);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = '400 13px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = M.overlay2;
|
||||||
|
ctx.fillText('www.psysonic.de', W / 2, H - FOOTER_H / 2 + 6);
|
||||||
|
|
||||||
|
return new Promise(resolve => canvas.toBlob(b => resolve(b!), 'image/png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportNewAlbumsImage(since: number): Promise<{ count: number; paths: string[] } | null> {
|
||||||
|
const albums = await getAlbumList('newest', 500);
|
||||||
|
if (albums.length === 0) return null;
|
||||||
|
|
||||||
|
const newAlbums = since > 0
|
||||||
|
? albums.filter(a => a.created && new Date(a.created).getTime() >= since)
|
||||||
|
: albums;
|
||||||
|
|
||||||
|
if (newAlbums.length === 0) return null;
|
||||||
|
|
||||||
|
newAlbums.sort((a, b) => a.artist.localeCompare(b.artist, 'de') || a.name.localeCompare(b.name, 'de'));
|
||||||
|
|
||||||
|
// Chunk into pages
|
||||||
|
const pages: SubsonicAlbum[][] = [];
|
||||||
|
for (let i = 0; i < newAlbums.length; i += MAX_PER_PAGE) {
|
||||||
|
pages.push(newAlbums.slice(i, i + MAX_PER_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const logo = await loadLogo();
|
||||||
|
const { downloadFolder } = useAuthStore.getState();
|
||||||
|
const folder = downloadFolder || await downloadDir();
|
||||||
|
const timestamp = now.toISOString().slice(0, 10);
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
const page = pages[p];
|
||||||
|
const covers = await Promise.all(
|
||||||
|
page.map(a => a.coverArt ? loadImage(buildCoverArtUrl(a.coverArt, 160)) : Promise.resolve(null))
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await renderPage(page, covers, logo, now, newAlbums.length, p + 1, pages.length, p * MAX_PER_PAGE);
|
||||||
|
const suffix = pages.length > 1 ? `-${p + 1}` : '';
|
||||||
|
const filename = `psysonic-new-albums-${timestamp}${suffix}.png`;
|
||||||
|
const filePath = await join(folder, filename);
|
||||||
|
await writeFile(filePath, new Uint8Array(await blob.arrayBuffer()));
|
||||||
|
paths.push(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count: newAlbums.length, paths };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user