Compare commits
4 Commits
app-v1.18.0
...
v1.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d27798eb | |||
| b35539d3cf | |||
| a1b3022140 | |||
| b6fb66c46d |
@@ -83,7 +83,7 @@ jobs:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
args: '--bundles nsis'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.20.0] - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Chinese language (zh)**: Full UI translation contributed by [@jiezhuo](https://github.com/jiezhuo). Language can be selected in Settings → General.
|
||||
- **Genres page** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: New page (sidebar: Tags icon) showing all server genres as coloured cards — icon watermark, genre name, album count. Cards are sorted by album count descending and deterministically colour-coded from the Catppuccin palette. Clicking a card opens the album list for that genre. Navigating back restores the previous scroll position.
|
||||
- **Genre filter on Albums, New Releases, Random Albums** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: A multi-select genre combobox in the page header lets you filter any of these views to one or more genres. Chips show selected genres; backspace removes the last one; clicking outside collapses the filter automatically when nothing is selected. In filter mode, results are fetched in parallel across all selected genres and deduped client-side.
|
||||
- **Settings — Contributors**: A new "Contributors" row in the About section credits community translators.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Theme — W10** *(Operating Systems)*: New Windows 10 Fluent Design light theme. Clean white content area, flat light-grey `#F3F3F3` navigation pane, near-black `#1C1C1C` taskbar player bar with a Windows-blue `#0078D4` accent stripe, flat buttons without gradients (4 px radius). Sharp, unmistakably W10 — distinct from the glass-era W7/Vista and the rounded-corner W11.
|
||||
- **ThemePicker — Windows themes sorted by release year**: W3.1 → W98 → WXP → Wista → W7 → W10 → W11.
|
||||
- **Playlists page — removed**: The dedicated Playlists page has been removed. Playlists remain fully accessible via the Queue panel (Save / Load buttons in the toolbar).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FLAC seeking** *(Rust audio engine)*: `rodio`'s internal `ReadSeekSource` hardcodes `byte_len() → None`, which caused the symphonia FLAC demuxer to reject all seek attempts (it validates seek byte offsets against the total stream length). Replaced `rodio::Decoder` with a direct symphonia pipeline (`SizedDecoder`) that wraps the audio bytes in a `SizedCursorSource` providing the correct `byte_len()`. FLAC seeking now works regardless of whether the file has an embedded SEEKTABLE.
|
||||
- **Genre missing in Queue meta box when playing from album card**: `playAlbum()` (used by the play button on all album cards) mapped song-level genre only — which Navidrome does not always return per song. Now falls back to the album-level genre from `getAlbum`. Same fallback applied to all three play/enqueue handlers in `AlbumDetail`.
|
||||
- **Logo gradient CSS variables**: Sidebar logo gradient now uses `--logo-color-start` / `--logo-color-end` with fallbacks, allowing themes with dark sidebars to override the gradient colours.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,7 +24,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
|
||||
@@ -36,6 +36,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
|
||||
@@ -56,7 +57,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with keyword filter & Super Genre mix
|
||||
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Internationalization (English, German, French, Dutch)
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
@@ -71,7 +72,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
## ● Known Limitations
|
||||
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.18.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -2302,9 +2302,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.18.0",
|
||||
"version": "1.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,7 +15,6 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.18.0
|
||||
pkgver=1.19.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+14
-247
@@ -85,18 +85,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -210,24 +198,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -1089,12 +1059,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -2445,18 +2409,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
@@ -2704,20 +2656,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -2845,7 +2783,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -3247,7 +3184,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3424,7 +3361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"md5",
|
||||
@@ -3433,12 +3370,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"symphonia",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-window-state",
|
||||
@@ -3451,15 +3388,6 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3515,16 +3443,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3545,16 +3463,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3573,15 +3481,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -4380,8 +4279,8 @@ dependencies = [
|
||||
"pollster",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.44.0",
|
||||
"zbus 3.15.2",
|
||||
"zvariant 3.15.2",
|
||||
"zbus",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4907,25 +4806,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -5078,18 +4958,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
@@ -6701,12 +6569,12 @@ version = "3.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6"
|
||||
dependencies = [
|
||||
"async-broadcast 0.5.1",
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io 1.13.0",
|
||||
"async-lock 2.8.0",
|
||||
"async-process 1.8.1",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
@@ -6731,44 +6599,9 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"winapi",
|
||||
"xdg-home",
|
||||
"zbus_macros 3.15.2",
|
||||
"zbus_names 2.6.1",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6782,22 +6615,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6808,18 +6626,7 @@ checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6919,21 +6726,7 @@ dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive 3.15.2",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"zvariant_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6946,20 +6739,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6972,16 +6752,3 @@ dependencies = [
|
||||
"quote",
|
||||
"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]
|
||||
name = "psysonic"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -23,7 +23,6 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
@@ -31,6 +30,7 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"core:default",
|
||||
"shell:default",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"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",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
@@ -6014,204 +6014,6 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
+297
-10
@@ -1,12 +1,21 @@
|
||||
use std::io::Cursor;
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||
use rodio::{Decoder, Sink, Source};
|
||||
use rodio::{Sink, Source};
|
||||
use rodio::source::UniformSourceIterator;
|
||||
use serde::Serialize;
|
||||
use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
units::{self, Time},
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
@@ -394,6 +403,264 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
|
||||
//
|
||||
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||||
// byte_len() → None. This tells symphonia "stream length unknown", which
|
||||
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||||
// the total stream length from byte_len). MP3 is unaffected because its
|
||||
// demuxer uses Xing/LAME headers instead.
|
||||
//
|
||||
// This wrapper provides the actual byte length, fixing seek for all formats.
|
||||
|
||||
struct SizedCursorSource {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl Read for SizedCursorSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for SizedCursorSource {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for SizedCursorSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
|
||||
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||||
//
|
||||
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||||
// (byte_len = None). This constructs the symphonia pipeline directly,
|
||||
// providing the correct byte_len via SizedCursorSource.
|
||||
//
|
||||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||
|
||||
const DECODE_MAX_RETRIES: usize = 3;
|
||||
|
||||
struct SizedDecoder {
|
||||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||
current_frame_offset: usize,
|
||||
format: Box<dyn FormatReader>,
|
||||
total_duration: Option<Time>,
|
||||
buffer: SampleBuffer<i16>,
|
||||
spec: SignalSpec,
|
||||
}
|
||||
|
||||
impl SizedDecoder {
|
||||
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||
let data_len = data.len() as u64;
|
||||
let source = SizedCursorSource {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("probe failed: {e}"))?;
|
||||
|
||||
let track = probed.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "no supported audio track".to_string())?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
.zip(track.codec_params.n_frames)
|
||||
.map(|(base, frames)| base.calc_time(frames));
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("codec init failed: {e}"))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
// Decode the first packet to initialise spec + buffer.
|
||||
let mut decode_errors: usize = 0;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
Err(e) => return Err(format!("first packet: {e}")),
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => break decoded,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("decode: {e}")),
|
||||
}
|
||||
};
|
||||
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
|
||||
Ok(SizedDecoder {
|
||||
decoder,
|
||||
current_frame_offset: 0,
|
||||
format,
|
||||
total_duration,
|
||||
buffer,
|
||||
spec,
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
|
||||
let duration = units::Duration::from(decoded.capacity() as u64);
|
||||
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
|
||||
buffer.copy_interleaved_ref(decoded);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Refine position after a coarse seek — decode packets until we reach the
|
||||
/// exact requested timestamp.
|
||||
fn refine_position(
|
||||
&mut self,
|
||||
seek_res: symphonia::core::formats::SeekedTo,
|
||||
) -> Result<(), String> {
|
||||
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||||
let packet = loop {
|
||||
let candidate = self.format.next_packet()
|
||||
.map_err(|e| format!("refine seek: {e}"))?;
|
||||
if candidate.dur() > samples_to_pass {
|
||||
break candidate;
|
||||
}
|
||||
samples_to_pass -= candidate.dur();
|
||||
};
|
||||
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet()
|
||||
.map_err(|e| format!("refine retry: {e}"))?;
|
||||
decoded = self.decoder.decode(&p);
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SizedDecoder {
|
||||
type Item = i16;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<i16> {
|
||||
if self.current_frame_offset >= self.buffer.len() {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet().ok()?;
|
||||
decoded = self.decoder.decode(&p);
|
||||
}
|
||||
}
|
||||
let decoded = decoded.ok()?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
}
|
||||
|
||||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||
self.current_frame_offset += 1;
|
||||
Some(sample)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SizedDecoder {
|
||||
#[inline]
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
Some(self.buffer.samples().len())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> u16 {
|
||||
self.spec.channels.count() as u16
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.spec.rate
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.total_duration.map(|Time { seconds, frac }| {
|
||||
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
|
||||
})
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
let seek_beyond_end = self
|
||||
.total_duration()
|
||||
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
|
||||
|
||||
let time: Time = if seek_beyond_end {
|
||||
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
|
||||
// Step back a tiny bit — some demuxers can't seek to the exact end.
|
||||
let mut secs = t.seconds;
|
||||
let mut frac = t.frac - 0.0001;
|
||||
if frac < 0.0 {
|
||||
secs = secs.saturating_sub(1);
|
||||
frac = 1.0 - frac;
|
||||
}
|
||||
Time { seconds: secs, frac }
|
||||
} else {
|
||||
pos.as_secs_f64().into()
|
||||
};
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||
))?;
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||||
//
|
||||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||||
@@ -473,6 +740,7 @@ struct BuiltSource {
|
||||
///
|
||||
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
||||
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
||||
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
|
||||
fn build_source(
|
||||
data: Vec<u8>,
|
||||
duration_hint: f64,
|
||||
@@ -482,11 +750,11 @@ fn build_source(
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
|
||||
let decoder = SizedDecoder::new(data, format_hint)?;
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
@@ -881,6 +1149,10 @@ pub async fn audio_play(
|
||||
// Reset sample counter for the new track.
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
// Extract format hint from URL for better symphonia probing.
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
@@ -890,6 +1162,7 @@ pub async fn audio_play(
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
@@ -1071,6 +1344,9 @@ pub async fn audio_chain_preload(
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
@@ -1080,6 +1356,7 @@ pub async fn audio_chain_preload(
|
||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||
chain_counter.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
@@ -1144,6 +1421,8 @@ fn spawn_progress_task(
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
// Local done-flag reference; swapped on gapless transition.
|
||||
let mut current_done = initial_done;
|
||||
// Local sample counter; swapped to chained source's counter on transition.
|
||||
let mut samples_played = samples_played;
|
||||
|
||||
loop {
|
||||
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
||||
@@ -1163,12 +1442,11 @@ fn spawn_progress_task(
|
||||
// Swap to the chained source's done flag.
|
||||
current_done = info.source_done;
|
||||
|
||||
// Swap the sample counter: the chained source's counter
|
||||
// is already being incremented by CountingSource. Copy its
|
||||
// current value into the shared samples_played so the
|
||||
// progress calculation stays accurate.
|
||||
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
|
||||
samples_played.store(chained_samples, Ordering::Relaxed);
|
||||
// Swap to the chained source's sample counter.
|
||||
// The chained CountingSource increments its own Arc,
|
||||
// so we must rebind our local reference to it —
|
||||
// a one-time value copy would go stale immediately.
|
||||
samples_played = info.sample_counter;
|
||||
|
||||
// Update tracking state.
|
||||
{
|
||||
@@ -1234,6 +1512,15 @@ fn spawn_progress_task(
|
||||
near_end_ticks += 1;
|
||||
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
||||
if near_end_ticks >= 10 {
|
||||
// If a gapless chain is pending, the source hasn't
|
||||
// exhausted yet — duration_hint (integer seconds from
|
||||
// Subsonic) is shorter than the actual audio content.
|
||||
// Don't emit audio:ended; let the gapless transition
|
||||
// handle it when current_done fires.
|
||||
let has_chain = chained_arc.lock().unwrap().is_some();
|
||||
if has_chain {
|
||||
continue;
|
||||
}
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
app.emit("audio:ended", ()).ok();
|
||||
break;
|
||||
|
||||
+2
-64
@@ -6,11 +6,7 @@ mod audio;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
@@ -298,61 +294,12 @@ pub fn run() {
|
||||
.manage(ShortcutMap::default())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
|
||||
.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 ──────────────────
|
||||
{
|
||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||
@@ -445,15 +392,6 @@ pub fn run() {
|
||||
|
||||
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![
|
||||
greet,
|
||||
exit_app,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.18.0",
|
||||
"version": "1.20.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -60,8 +60,8 @@
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
-15
@@ -22,7 +22,6 @@ import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
@@ -36,6 +35,9 @@ import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
@@ -199,7 +201,6 @@ function AppShell() {
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
@@ -207,6 +208,8 @@ function AppShell() {
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -230,12 +233,11 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
// Tray / media key event handler
|
||||
// Media key event handler
|
||||
function TauriEventBridge() {
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const { minimizeToTray } = useAuthStore();
|
||||
|
||||
// Configurable keybindings
|
||||
useEffect(() => {
|
||||
@@ -295,8 +297,6 @@ function TauriEventBridge() {
|
||||
['media:prev', () => previous()],
|
||||
['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)); }],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
];
|
||||
for (const [event, handler] of handlers) {
|
||||
const u = await listen(event, handler);
|
||||
@@ -326,15 +326,10 @@ function TauriEventBridge() {
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
// Close → exit app
|
||||
const win = getCurrentWindow();
|
||||
const u = await win.onCloseRequested(async (event) => {
|
||||
if (minimizeToTray) {
|
||||
event.preventDefault();
|
||||
await win.hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
}
|
||||
const u = await win.onCloseRequested(async () => {
|
||||
await invoke('exit_app');
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
@@ -342,7 +337,7 @@ function TauriEventBridge() {
|
||||
|
||||
setup();
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous, minimizeToTray]);
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -350,6 +345,7 @@ function TauriEventBridge() {
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const font = useFontStore(s => s.font);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -367,6 +363,67 @@ export default function App() {
|
||||
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 (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
@@ -381,6 +438,7 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-2
@@ -52,6 +52,7 @@ export interface SubsonicAlbum {
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -229,8 +230,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
|
||||
}
|
||||
|
||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
|
||||
return data.genres?.genre ?? [];
|
||||
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
||||
const raw = data.genres?.genre;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'byGenre', genre, size, offset, _t: Date.now(),
|
||||
});
|
||||
const raw = data.albumList2?.album;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track } from '../store/playerStore';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -45,8 +45,6 @@ interface AlbumTrackListProps {
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
hoveredSongId: string | null;
|
||||
setHoveredSongId: (id: string | null) => void;
|
||||
ratings: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
@@ -60,8 +58,6 @@ export default function AlbumTrackList({
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
hoveredSongId,
|
||||
setHoveredSongId,
|
||||
ratings,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
@@ -70,6 +66,12 @@ export default function AlbumTrackList({
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
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 discs = new Map<number, SubsonicSong[]>();
|
||||
@@ -112,12 +114,13 @@ export default function AlbumTrackList({
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
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)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -17,7 +17,22 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getGenres } from '../api/subsonic';
|
||||
|
||||
interface GenreFilterBarProps {
|
||||
selected: string[];
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export default function GenreFilterBar({ selected, onSelectionChange }: GenreFilterBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [genres, setGenres] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGenres().then(data =>
|
||||
setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b)))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// sync open state with selection
|
||||
useEffect(() => {
|
||||
if (selected.length > 0) setOpen(true);
|
||||
}, [selected]);
|
||||
|
||||
const filteredOptions = genres.filter(
|
||||
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const add = (genre: string) => {
|
||||
onSelectionChange([...selected, genre]);
|
||||
setSearch('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const remove = (genre: string) => {
|
||||
onSelectionChange(selected.filter(s => s !== genre));
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onSelectionChange([]);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const openFilter = () => {
|
||||
setOpen(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<Filter size={14} />
|
||||
{t('common.filterGenre')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
// relatedTarget is the next focused element; if it's outside our container, handle close
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (containerRef.current && next && containerRef.current.contains(next)) return;
|
||||
setTimeout(() => {
|
||||
if (selected.length === 0) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setDropdownOpen(false);
|
||||
} else {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
|
||||
<div className="genre-filter-tagbox">
|
||||
{selected.map(g => (
|
||||
<span key={g} className="genre-filter-chip">
|
||||
{g}
|
||||
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="genre-filter-input"
|
||||
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
|
||||
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
|
||||
remove(selected[selected.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{dropdownOpen && filteredOptions.length > 0 && (
|
||||
<div className="genre-filter-dropdown">
|
||||
{filteredOptions.slice(0, 60).map(g => (
|
||||
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
|
||||
{g}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
|
||||
<div className="genre-filter-dropdown">
|
||||
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
|
||||
<X size={13} />
|
||||
{t('common.filterClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ export default function PsysonicLogo({ className, style }: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id="psysonicGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
||||
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -19,7 +19,7 @@ const navItems = [
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
|
||||
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
|
||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
];
|
||||
|
||||
@@ -16,8 +16,8 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ 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: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
|
||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
|
||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
|
||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
|
||||
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||
{ 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: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||
{ 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: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
||||
],
|
||||
@@ -60,15 +60,18 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Operating Systems',
|
||||
themes: [
|
||||
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
|
||||
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
|
||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
|
||||
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
||||
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
||||
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
||||
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
|
||||
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
|
||||
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
||||
],
|
||||
},
|
||||
@@ -92,7 +95,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
||||
{ 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);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
|
||||
+700
-93
File diff suppressed because it is too large
Load Diff
@@ -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 { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -43,7 +44,7 @@ export default function AlbumDetail() {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
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 offlineTracks = useOfflineStore(s => s.tracks);
|
||||
@@ -90,32 +91,35 @@ export default function AlbumDetail() {
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
starred: s.starred, genre: s.genre,
|
||||
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||
}));
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
starred: s.starred, genre: s.genre,
|
||||
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const albumGenre = album?.album.genre;
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
starred: song.starred, genre: song.genre,
|
||||
starred: song.starred, genre: song.genre ?? albumGenre,
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -207,10 +211,21 @@ export default function AlbumDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCacheOffline = () => {
|
||||
const handleCacheOffline = useCallback(async () => {
|
||||
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);
|
||||
};
|
||||
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
|
||||
|
||||
const handleRemoveOffline = () => {
|
||||
if (!album) return;
|
||||
@@ -251,14 +266,24 @@ export default function AlbumDetail() {
|
||||
onCacheOffline={handleCacheOffline}
|
||||
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
|
||||
songs={songs}
|
||||
hasVariousArtists={hasVariousArtists}
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
hoveredSongId={hoveredSongId}
|
||||
setHoveredSongId={setHoveredSongId}
|
||||
ratings={ratings}
|
||||
starredSongs={new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
|
||||
+45
-21
@@ -1,10 +1,19 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||
}
|
||||
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -12,9 +21,9 @@ export default function Albums() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
||||
setLoading(true);
|
||||
@@ -28,27 +37,40 @@ export default function Albums() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setPage(0); load(sort, 0); }, [sort, load]);
|
||||
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchByGenres(genres);
|
||||
const sorted = [...data].sort((a, b) =>
|
||||
sortType === 'alphabeticalByArtist'
|
||||
? a.artist.localeCompare(b.artist)
|
||||
: a.name.localeCompare(b.name)
|
||||
);
|
||||
setAlbums(sorted);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filtered) loadFiltered(selectedGenres, sort);
|
||||
else { setPage(0); load(sort, 0); }
|
||||
}, [sort, filtered, selectedGenres, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
if (loading || !hasMore || filtered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(sort, next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, sort, load]);
|
||||
}, [loading, hasMore, page, sort, load, filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
@@ -59,9 +81,9 @@ export default function Albums() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title">{t('albums.title')}</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
@@ -72,6 +94,7 @@ export default function Albums() {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,10 +107,11 @@ export default function Albums() {
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
{!filtered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function GenreDetail() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const genre = decodeURIComponent(name ?? '');
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setAlbums([]);
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setLoading(true);
|
||||
getAlbumsByGenre(genre, PAGE_SIZE, 0)
|
||||
.then(data => {
|
||||
setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
setOffset(PAGE_SIZE);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [genre]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
getAlbumsByGenre(genre, PAGE_SIZE, offset)
|
||||
.then(data => {
|
||||
setAlbums(prev => [...prev, ...data]);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
setOffset(prev => prev + PAGE_SIZE);
|
||||
})
|
||||
.finally(() => setLoadingMore(false));
|
||||
}, [genre, offset, loadingMore, hasMore]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>{t('genres.back')}</span>
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
|
||||
{!loading && albums.length > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
|
||||
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
||||
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
|
||||
|
||||
{albums.length > 0 && (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && !loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
|
||||
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
|
||||
Tags, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
||||
|
||||
function getGenreIcon(name: string): LucideIcon {
|
||||
const n = name.toLowerCase();
|
||||
if (/ambient|drone|new age/.test(n)) return Cloud;
|
||||
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
|
||||
if (/rock/.test(n)) return Radio;
|
||||
if (/jazz/.test(n)) return Music2;
|
||||
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
|
||||
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
|
||||
if (/hip.?hop|rap/.test(n)) return Mic;
|
||||
if (/pop/.test(n)) return Star;
|
||||
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
|
||||
if (/blues/.test(n)) return Music2;
|
||||
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
|
||||
if (/reggae|ska|dub/.test(n)) return Sun;
|
||||
if (/punk/.test(n)) return Flame;
|
||||
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
|
||||
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
|
||||
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
|
||||
if (/podcast/.test(n)) return Podcast;
|
||||
return Headphones;
|
||||
}
|
||||
|
||||
const CTP_COLORS = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
||||
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
||||
];
|
||||
|
||||
function genreColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return CTP_COLORS[h % CTP_COLORS.length];
|
||||
}
|
||||
|
||||
const SCROLL_KEY = 'genres-scroll';
|
||||
|
||||
export default function Genres() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGenres()
|
||||
.then(data => {
|
||||
const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount);
|
||||
setGenres(sorted);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Restore scroll position after genres are rendered
|
||||
useEffect(() => {
|
||||
if (loading || genres.length === 0) return;
|
||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (!saved) return;
|
||||
const pos = parseInt(saved, 10);
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = pos;
|
||||
});
|
||||
}, [loading, genres.length]);
|
||||
|
||||
const handleGenreClick = (genreValue: string) => {
|
||||
if (containerRef.current) {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop));
|
||||
}
|
||||
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
|
||||
{!loading && genres.length > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
<Tags size={14} style={{ color: 'var(--accent)' }} />
|
||||
{genres.length} {t('genres.genreCount')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
||||
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
||||
|
||||
{!loading && genres.length > 0 && (
|
||||
<div className="album-grid-wrap">
|
||||
{genres.map(genre => {
|
||||
const Icon = getGenreIcon(genre.value);
|
||||
const color = genreColor(genre.value);
|
||||
return (
|
||||
<div
|
||||
key={genre.value}
|
||||
className="genre-card"
|
||||
style={{ '--genre-color': color } as React.CSSProperties}
|
||||
onClick={() => handleGenreClick(genre.value)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
|
||||
data-tooltip={genre.value}
|
||||
>
|
||||
<div className="genre-card-watermark">
|
||||
<Icon size={80} strokeWidth={1.2} />
|
||||
</div>
|
||||
<p className="genre-card-name">{genre.value}</p>
|
||||
<p className="genre-card-count">
|
||||
{t('genres.albumCount', { count: genre.albumCount })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-4
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
@@ -65,7 +65,6 @@ export default function Help() {
|
||||
items: [
|
||||
{ q: t('help.q12'), a: t('help.a12') },
|
||||
{ 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.q31'), a: t('help.a31') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
@@ -88,6 +87,15 @@ export default function Help() {
|
||||
{ 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} />,
|
||||
title: t('help.s6'),
|
||||
@@ -104,9 +112,9 @@ export default function Help() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<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) => (
|
||||
<section key={si} className="settings-section">
|
||||
<section key={si} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
|
||||
<div className="settings-section-header">
|
||||
{section.icon}
|
||||
<h2>{section.title}</h2>
|
||||
|
||||
+41
-20
@@ -1,17 +1,27 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||
return union.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||
}
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const load = useCallback(async (offset: number, append = false) => {
|
||||
setLoading(true);
|
||||
@@ -25,34 +35,44 @@ export default function NewReleases() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setPage(0); load(0); }, [load]);
|
||||
const loadFiltered = useCallback(async (genres: string[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAlbums(await fetchByGenres(genres));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filtered) loadFiltered(selectedGenres);
|
||||
else { setPage(0); load(0); }
|
||||
}, [filtered, selectedGenres, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
if (loading || !hasMore || filtered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, load]);
|
||||
}, [loading, hasMore, page, load, filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('sidebar.newReleases')}</h1>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
@@ -62,10 +82,11 @@ export default function NewReleases() {
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
{!filtered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortKey = 'name' | 'songCount' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label, sortKey, current, dir, onSort
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
current: SortKey;
|
||||
dir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
}) {
|
||||
const active = current === sortKey;
|
||||
return (
|
||||
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
|
||||
{label}
|
||||
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists()
|
||||
.then(data => { setPlaylists(data); setLoading(false); })
|
||||
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
|
||||
};
|
||||
|
||||
useEffect(() => { fetchPlaylists(); }, []);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const handlePlay = async (id: string) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks = data.songs.map((s: any) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||
coverArt: s.coverArt, track: s.track, year: s.year,
|
||||
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
} catch (e) { console.error('Failed to play playlist', e); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('playlists.confirmDelete', { name }))) {
|
||||
try { await deletePlaylist(id); fetchPlaylists(); }
|
||||
catch (e) { console.error('Failed to delete playlist', e); }
|
||||
}
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.toLowerCase();
|
||||
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
|
||||
else cmp = a.duration - b.duration;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [playlists, filter, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="playlist-page-header">
|
||||
<h1 className="page-title">{t('playlists.title')}</h1>
|
||||
<input
|
||||
className="playlist-filter-input"
|
||||
type="search"
|
||||
placeholder={t('playlists.filterPlaceholder')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
|
||||
) : (
|
||||
<div className="playlist-list">
|
||||
<div className="playlist-list-header">
|
||||
<div />
|
||||
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.noResults')}</div>
|
||||
) : visible.map(p => (
|
||||
<div key={p.id} className="playlist-row">
|
||||
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<span className="playlist-name truncate">{p.name}</span>
|
||||
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
|
||||
<span className="playlist-meta">{formatDuration(p.duration)}</span>
|
||||
<button
|
||||
className="btn btn-ghost playlist-delete-btn"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-14
@@ -1,23 +1,40 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = union.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[union[i], union[j]] = [union[j], union[i]];
|
||||
}
|
||||
return union.slice(0, ALBUM_COUNT);
|
||||
}
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const loadingRef = useRef(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (genres: string[]) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList('random', ALBUM_COUNT);
|
||||
const data = genres.length > 0
|
||||
? await fetchByGenres(genres)
|
||||
: await getAlbumList('random', ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -27,21 +44,24 @@ export default function RandomAlbums() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => load(selectedGenres)}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
|
||||
+5
-13
@@ -552,6 +552,7 @@ export default function Settings() {
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -944,19 +945,6 @@ export default function Settings() {
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<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>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
@@ -1029,6 +1017,10 @@ export default function Settings() {
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutContributorsLabel')}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutContributors')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -21,7 +21,6 @@ interface AuthState {
|
||||
lastfmUsername: string;
|
||||
|
||||
// Settings (global)
|
||||
minimizeToTray: boolean;
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
downloadFolder: string;
|
||||
@@ -51,7 +50,6 @@ interface AuthState {
|
||||
connectLastfm: (sessionKey: string, username: string) => void;
|
||||
disconnectLastfm: () => void;
|
||||
setLastfmSessionError: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
@@ -82,7 +80,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
lastfmApiSecret: '',
|
||||
lastfmSessionKey: '',
|
||||
lastfmUsername: '',
|
||||
minimizeToTray: false,
|
||||
scrobblingEnabled: true,
|
||||
maxCacheMb: 500,
|
||||
downloadFolder: '',
|
||||
@@ -139,7 +136,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
||||
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11';
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
@@ -4055,3 +4055,147 @@
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ─ Genre Filter Bar ─ */
|
||||
.genre-filter-tagbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
min-width: 220px;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.genre-filter-tagbox:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.3rem 0.15rem 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.genre-filter-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.75;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.genre-filter-chip button:hover { opacity: 1; }
|
||||
|
||||
.genre-filter-input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.genre-filter-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.genre-filter-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.genre-filter-option {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.genre-filter-option:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-empty {
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─ Genre Cards ─ */
|
||||
.genre-card {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background:
|
||||
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
|
||||
var(--genre-color, var(--accent));
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.genre-card:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.genre-card-watermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -10px;
|
||||
transform: translateY(-55%);
|
||||
opacity: 0.22;
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.genre-card-name {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.genre-card-count {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
@@ -413,6 +413,40 @@
|
||||
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 {
|
||||
grid-area: player;
|
||||
|
||||
+2038
-430
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 };
|
||||
}
|
||||
@@ -20,7 +20,12 @@ function fadeOut(setVolume: (v: number) => void, from: number, durationMs: numbe
|
||||
|
||||
export async function playAlbum(albumId: string): Promise<void> {
|
||||
const albumData = await getAlbum(albumId);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
const albumGenre = albumData.album.genre;
|
||||
const tracks = albumData.songs.map(s => {
|
||||
const track = songToTrack(s);
|
||||
if (!track.genre && albumGenre) track.genre = albumGenre;
|
||||
return track;
|
||||
});
|
||||
if (!tracks.length) return;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
|
||||
Reference in New Issue
Block a user