mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-26 09:07:41 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2d07fad73 | |||
| e0d1af70a2 | |||
| c037ab459a | |||
| 955a9fcbd6 | |||
| c428d37e0e | |||
| 4225146a16 | |||
| d50c9c444d | |||
| 99c0b6cdac | |||
| ee044ece1a | |||
| fde7ab432f | |||
| f28e82c022 | |||
| 0f580f58c8 | |||
| a6ee0668c8 | |||
| ed52a9991f | |||
| ad74578ef6 | |||
| ccb2d11fc4 | |||
| 44d373d7bb | |||
| 116196f0d4 | |||
| 68b21643f8 | |||
| 8498d5a566 | |||
| 3ec65a6407 | |||
| 47b09d6f25 | |||
| 1e956d6043 | |||
| 82967caa9c | |||
| a6122f9db4 | |||
| 6d63365c2a | |||
| 6168e81195 | |||
| 067ed00ae2 | |||
| 16e562b42d | |||
| 961dba996c | |||
| 4fd558fa28 | |||
| 42dcbb9323 | |||
| 997e697a53 | |||
| 4c0dfaaada | |||
| e563749ace | |||
| 15fb0f6c56 | |||
| 6d404fdc2d | |||
| c453f01b94 |
+165
@@ -9,6 +9,171 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Added
|
||||
|
||||
### Theme store — version numbers and an animated/static filter
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1104](https://github.com/Psychotoxical/psysonic/pull/1104)**
|
||||
|
||||
* Theme versions now show in the store (next to the author) and under each installed community theme; when an update is available, the store shows the installed → available version.
|
||||
* New store filter to show only animated themes or only static ones, next to the existing mode and sort controls.
|
||||
|
||||
### Playlist folders
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1119](https://github.com/Psychotoxical/psysonic/pull/1119)**, suggested by [@SilverWolf24](https://github.com/SilverWolf24)
|
||||
|
||||
* Organise your playlists into folders on the Playlists page and in the sidebar — create folders, drag playlists into them (or use the right-click "Move to folder" menu), rename, collapse and switch between the folder view and a single flat list. Folders are saved locally on this device only, since the Subsonic API has no folder support.
|
||||
|
||||
### AutoDJ — content-aware crossfade
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122) and [@Psychotoxical](https://github.com/Psychotoxical), PR [#1124](https://github.com/Psychotoxical/psysonic/pull/1124)**
|
||||
|
||||
* New **AutoDJ** crossfade mode. Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
|
||||
* AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged.
|
||||
|
||||
### AutoDJ — smooth skip and interrupt blend
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1128](https://github.com/Psychotoxical/psysonic/pull/1128)**
|
||||
|
||||
* New **Smooth skip** toggle under Settings → Audio → Track transitions (on by default when AutoDJ is active). Manual Next/Previous and picking a track from the library, an album, or the infinite queue crossfade from where you are listening instead of hard-cutting.
|
||||
* Loud→loud queue advances use a consistent ~2s musical blend; manual skips cap at the same length so quiet intros are not drowned out.
|
||||
* When the target track is not buffered yet, the player briefly ducks the outgoing track while preloading; the player bar keeps showing the current song until the handoff so titles and artwork do not flicker or pause spuriously.
|
||||
* During an active blend, the play/pause button shows a pulsing Blend icon.
|
||||
|
||||
### Play queue sync — cross-device handoff
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1131](https://github.com/Psychotoxical/psysonic/pull/1131)**, closes [#1129](https://github.com/Psychotoxical/psysonic/issues/1129)
|
||||
|
||||
* Manual **pull** from the header connection indicator (LED + sync ring): click to fetch the active server's play queue when it differs from the local player; no-op when already in sync. Yellow LED when browse server ≠ playback server (e.g. after switching servers).
|
||||
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
|
||||
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Settings — consistent grouped layout
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1126](https://github.com/Psychotoxical/psysonic/pull/1126) and PR [#1130](https://github.com/Psychotoxical/psysonic/pull/1130)**
|
||||
|
||||
* The settings tabs now group related controls into clearly bordered, labelled panels for a more consistent, easier-to-scan layout — across Appearance, System, Audio, Storage, Library, Integrations, Music Network, Lyrics, Personalisation, Input and Themes. Standalone toggles are left as they were, and a few duplicated section titles are gone.
|
||||
* The **Lucky Mix menu** toggle moved from the Library tab to the sidebar customizer, alongside the other navigation toggles.
|
||||
* The **Native Hi-Res Playback** description now explains what turning it on actually does — play each track at its original sample rate, matching the audio device to the file, instead of resampling everything to 44.1 kHz. The old wording described the off state and read as if the option forced 44.1 kHz.
|
||||
* **Settings → Audio**: **Normalization** and **Track transitions** are now their own top-level categories (directly under Audio Output Device) instead of being grouped together inside one *Playback* section.
|
||||
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
|
||||
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Seeking in streamed Opus/Ogg tracks
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1110](https://github.com/Psychotoxical/psysonic/pull/1110)**
|
||||
|
||||
* Scrubbing an Opus/Ogg track that was still streaming did nothing — the seekbar snapped back, and seeking only worked once the track had fully downloaded. Seeking now works mid-stream: the player fetches just the part of the file it needs over HTTP instead of waiting for the whole track to download. Cached and local files are unchanged. (Follow-up to the 1.48.1 Opus/Ogg seek-crash fix, #1100, which made streamed seeking a safe no-op rather than a crash.)
|
||||
|
||||
### Media buttons missing from the Windows taskbar preview
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1112](https://github.com/Psychotoxical/psysonic/pull/1112)**
|
||||
|
||||
* The Previous / Play-Pause / Next buttons in the Windows taskbar thumbnail preview (the popup shown when hovering the taskbar icon) had stopped appearing. They are back, and the middle button's icon again reflects the current playback state.
|
||||
|
||||
### Album sorting within artists
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1115](https://github.com/Psychotoxical/psysonic/pull/1115), PR [#1120](https://github.com/Psychotoxical/psysonic/pull/1120)**, suggested by [@kingley82](https://github.com/kingley82)
|
||||
|
||||
* When browsing albums sorted by artist, each artist's albums appeared in an arbitrary order. They are now ordered A–Z by album title within each artist.
|
||||
* New **Artist → Year** sort option groups albums by artist and orders each artist's albums chronologically (oldest first).
|
||||
|
||||
### "Add to playlist" from the player bar added the whole album
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1117](https://github.com/Psychotoxical/psysonic/pull/1117)**
|
||||
|
||||
* Right-clicking the current track in the player bar opened an album menu, so "Add to playlist" added the entire album instead of the playing song. The player bar menu now acts on the current song.
|
||||
|
||||
### Security — transitive form-data CRLF injection (GHSA-hmw2-7cc7-3qxx)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1118](https://github.com/Psychotoxical/psysonic/pull/1118)**
|
||||
|
||||
* Bumped transitive `form-data` 4.0.5 → 4.0.6 (via axios) to close Dependabot alert [#18](https://github.com/Psychotoxical/psysonic/security/dependabot/18) for CRLF injection in multipart field names (CVE-2026-12143). Psysonic only uses axios for GET requests, so exploitability was low; the lockfile bump clears the advisory.
|
||||
|
||||
### Live listener badge stale when the popover was closed
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1125](https://github.com/Psychotoxical/psysonic/pull/1125)**
|
||||
|
||||
* The Live header badge only refreshed `getNowPlaying` while the "Who is listening?" popover was open, so the listener count could stay stale or hidden until opened. Poll every 30 s while the window is visible (10 s while the popover is open); background fetches are silent so the header does not flash a loading state.
|
||||
|
||||
### Niri compositor tiling WM detection
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1127](https://github.com/Psychotoxical/psysonic/pull/1127)**
|
||||
|
||||
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
|
||||
|
||||
### Play queue idle pull overwrote local edits
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
|
||||
|
||||
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
|
||||
|
||||
|
||||
## [1.48.1] - 2026-06-15
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback freeze on track changes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
|
||||
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
|
||||
|
||||
### Paused or stopped playback restarting on headphone disconnect (macOS)
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
|
||||
|
||||
### Crash when seeking Opus/Ogg files
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
|
||||
|
||||
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
|
||||
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
|
||||
|
||||
### Discord Rich Presence cover art missing with two server addresses
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
|
||||
|
||||
### "Minimize to Tray" ignored on the macOS close button
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
|
||||
|
||||
### Library sync stalling for many seconds on large Navidrome collections
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
|
||||
|
||||
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
|
||||
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
|
||||
|
||||
### Album cover missing in Windows media controls
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
|
||||
|
||||
### Windows media controls showed "Unknown application"
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -14,7 +14,7 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **A
|
||||
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
|
||||
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
@@ -134,7 +134,7 @@ Start a session, invite others with a link and listen together with host-control
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer |
|
||||
| Windows | Native installer / WinGet |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
@@ -154,8 +154,12 @@ Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
or,
|
||||
install via Windows Package Manager (WinGet):
|
||||
```powershell
|
||||
winget install Psysonic
|
||||
```
|
||||
## macOS
|
||||
|
||||
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
|
||||
@@ -7,6 +7,74 @@ current line before promoting to `next` / `release`. Technical details and PR cr
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
|
||||
### Windows
|
||||
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
|
||||
"npmDepsHash": "sha256-oYSc00guRNrZhWTkRXYlqwSeVZ7tkz+/rA+XOF3NUvw="
|
||||
}
|
||||
|
||||
Generated
+10
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -2466,16 +2466,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2592,9 +2592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.46.0
|
||||
pkgver=1.48.1
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+32
-7
@@ -3496,6 +3496,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-javascript-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
@@ -3520,6 +3530,17 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
@@ -3563,6 +3584,8 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-javascript-core",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4114,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4127,6 +4150,7 @@ dependencies = [
|
||||
"lofty",
|
||||
"mach2",
|
||||
"md5",
|
||||
"objc2-web-kit",
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
@@ -4160,6 +4184,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-nvidia-quirk",
|
||||
"webp",
|
||||
"webview2-com",
|
||||
"windows 0.62.2",
|
||||
"zbus 5.16.0",
|
||||
"zip 8.6.0",
|
||||
@@ -4167,7 +4192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,7 +4241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -4225,7 +4250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4242,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4257,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -83,6 +83,7 @@ libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mach2 = "0.5"
|
||||
objc2-web-kit = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
@@ -91,6 +92,7 @@ webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
webkit2gtk-nvidia-quirk = "1.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
webview2-com = "0.38"
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
|
||||
@@ -40,6 +40,8 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::sink_swap::{
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
|
||||
SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::preserve_pitch_will_run;
|
||||
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
@@ -57,10 +57,29 @@ pub async fn audio_play(
|
||||
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
|
||||
// `false` so older/external `audio_play` callers that omit it still work.
|
||||
start_paused: Option<bool>,
|
||||
// Silence-aware crossfade (B-head): begin playback past the next track's
|
||||
// leading silence. Optional + defaults to `0` so existing callers are
|
||||
// unaffected; only applied when the freshly built source is seekable.
|
||||
start_secs: Option<f64>,
|
||||
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
|
||||
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
|
||||
// transition instead of the global `crossfade_secs`. `None` → use the global
|
||||
// setting (today's behaviour); always still clamped to the measured remaining.
|
||||
crossfade_secs_override: Option<f32>,
|
||||
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
|
||||
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
|
||||
// already fades out in the recording, so let it ride at full engine gain
|
||||
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
|
||||
// B's fade (today's behaviour). Always clamped to A's measured remaining.
|
||||
outgoing_fade_secs_override: Option<f32>,
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let start_paused = start_paused.unwrap_or(false);
|
||||
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
@@ -220,9 +239,15 @@ pub async fn audio_play(
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
|
||||
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
|
||||
let crossfade_enabled =
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = crossfade_secs_override
|
||||
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
|
||||
.clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
@@ -247,6 +272,19 @@ pub async fn audio_play(
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
|
||||
// override means A already fades out in the recording, so we leave it at
|
||||
// full engine gain (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -286,8 +324,9 @@ pub async fn audio_play(
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let source_seekable = playback_source.is_seekable;
|
||||
let built = playback_source.built;
|
||||
let source = built.source;
|
||||
let mut source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
let output_channels = built.output_channels;
|
||||
@@ -397,6 +436,17 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
// Silence-aware crossfade (B-head): skip the next track's leading silence by
|
||||
// seeking the freshly built source before it is appended. The outermost
|
||||
// `CountingSource` stores the sample counter on a successful seek; we still
|
||||
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
|
||||
// so the seekbar and the crossfade-remaining math are content-relative.
|
||||
let did_start_seek = if start_secs > 0.05 && source_seekable {
|
||||
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
@@ -423,9 +473,29 @@ pub async fn audio_play(
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
|
||||
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
|
||||
// sample counter to the content offset so position reporting is correct.
|
||||
if did_start_seek {
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.seek_offset = start_secs;
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
start_secs,
|
||||
output_rate,
|
||||
output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
if defer_playback_start {
|
||||
if !start_paused {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -455,6 +525,7 @@ pub async fn audio_play(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
|
||||
@@ -169,8 +169,14 @@ impl SizedDecoder {
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
//
|
||||
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
|
||||
// otherwise its demuxer never records `phys_byte_range_end` and the first
|
||||
// seek panics (see `container_hint_is_ogg`). This source is fully
|
||||
// in-memory, so the trailing-metadata scan it re-enables is free.
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !crate::stream::container_hint_is_ogg(format_hint);
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
@@ -315,19 +321,33 @@ impl SizedDecoder {
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
/// `source_random_access`: the underlying source can cheaply seek to EOF
|
||||
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
|
||||
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
|
||||
pub(crate) fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
source_random_access: bool,
|
||||
) -> Result<Self, String> {
|
||||
// For non-MP4 progressive streams, hide seekability during the probe so
|
||||
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
|
||||
// and block until the whole file is downloaded). Re-enabled right after.
|
||||
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
|
||||
// prefetched separately).
|
||||
//
|
||||
// Ogg also keeps seekability through the probe, but only on random-access
|
||||
// sources: its demuxer records `phys_byte_range_end` during the probe and
|
||||
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
|
||||
// local file the stream-end scan is cheap; on a progressive ranged stream
|
||||
// it would force a full download, so there we keep the gate and accept
|
||||
// that seeking is a no-op (the panic itself is contained in `try_seek`).
|
||||
let stream_len = media.byte_len();
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let ogg_needs_seekable_probe =
|
||||
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !ogg_needs_seekable_probe;
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
|
||||
None => media,
|
||||
@@ -588,20 +608,36 @@ impl Source for SizedDecoder {
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||
))?;
|
||||
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
|
||||
// `None` in `OggReader::do_seek`) on some streams instead of returning
|
||||
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
|
||||
// panic poisons the engine mutexes and then aborts the whole process at
|
||||
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
|
||||
// symptom of that poison). Contain the unwind here — including the packet
|
||||
// reads in `refine_position`, which can hit the same broken demuxer state —
|
||||
// and surface it as a recoverable `SeekError` so the engine stays alive
|
||||
// (the seek becomes a no-op rather than killing playback).
|
||||
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.refine_position(seek_res)?;
|
||||
Ok::<(), String>(())
|
||||
}));
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
match seek_outcome {
|
||||
Ok(Ok(())) => {
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other(e),
|
||||
))),
|
||||
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other("seek panicked inside the demuxer (contained)"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,8 +1055,9 @@ mod tests {
|
||||
#[test]
|
||||
fn new_streaming_constructs_from_synthetic_wav() {
|
||||
let wav = synthetic_wav_bytes(0.5);
|
||||
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
|
||||
.expect("streaming WAV decode setup");
|
||||
let decoder =
|
||||
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
|
||||
.expect("streaming WAV decode setup");
|
||||
assert_eq!(decoder.spec.rate(), 44_100);
|
||||
assert_eq!(decoder.spec.channels().count(), 1);
|
||||
// Live streams report no total duration.
|
||||
@@ -1033,6 +1070,7 @@ mod tests {
|
||||
seekable_source(vec![0x00u8; 64]),
|
||||
None,
|
||||
"test-stream",
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
@@ -211,6 +212,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
@@ -260,6 +262,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
engine.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
|
||||
@@ -82,6 +82,15 @@ pub(crate) async fn reopen_output_stream(
|
||||
if !opened {
|
||||
return false;
|
||||
}
|
||||
// When we're not actively playing (paused/stopped), bump the generation
|
||||
// before stopping the old sink so the still-running progress task sees the
|
||||
// mismatch and bails out instead of emitting a spurious `audio:ended` —
|
||||
// which would otherwise trigger a frontend restart of paused playback
|
||||
// (#1094). The active-playback path bumps inside
|
||||
// `try_resume_after_device_change`, so only guard the non-playing case here.
|
||||
if !snapshot.is_playing {
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,15 @@ pub struct AudioEngine {
|
||||
pub(crate) stream_playback_armed: Arc<AtomicBool>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
/// AutoDJ: when true, the progress task does NOT fire its autonomous
|
||||
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
|
||||
/// drives every advance (gated on the next track being playable). Prevents
|
||||
/// the engine from starting a still-buffering next track and fading over it
|
||||
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
|
||||
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
|
||||
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
|
||||
/// outgoing sink; block normalization/volume ramps until the handoff swap.
|
||||
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
@@ -475,6 +484,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
|
||||
@@ -805,6 +805,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
|
||||
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Cancel any in-flight sink-volume ramp (new ramp wins).
|
||||
pub(crate) fn cancel_sink_volume_ramp() {
|
||||
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
|
||||
/// interrupt prep or a mid-ramp correction.
|
||||
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
|
||||
sink.volume().clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
@@ -812,8 +825,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
let delta = (to - from).abs();
|
||||
// Stretch large corrections to avoid audible "step down" moments.
|
||||
@@ -826,18 +838,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
} else {
|
||||
(8, 16)
|
||||
};
|
||||
for i in 1..=steps {
|
||||
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
|
||||
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
if (to - from).abs() < 0.002 {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let secs = secs.clamp(0.1, 12.0);
|
||||
let step_ms: u64 = 20;
|
||||
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
|
||||
std::thread::spawn(move || {
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
fn ramp_sink_volume_steps(
|
||||
sink: Arc<Player>,
|
||||
from: f32,
|
||||
to: f32,
|
||||
steps: usize,
|
||||
step_ms: u64,
|
||||
my_gen: u64,
|
||||
) {
|
||||
for i in 1..=steps {
|
||||
if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,9 +13,9 @@ use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
@@ -105,11 +105,19 @@ pub fn audio_update_replay_gain(
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
||||
// ramp A back to full gain before the handoff swap.
|
||||
return;
|
||||
}
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
@@ -143,6 +151,34 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
||||
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
||||
#[tauri::command]
|
||||
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
||||
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let Some(sink) = cur.sink.as_ref() else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(true, Ordering::Relaxed);
|
||||
cancel_sink_volume_ramp();
|
||||
let from = sink_volume_now(sink);
|
||||
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
||||
}
|
||||
|
||||
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
||||
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
||||
/// (only when the next track is actually playable). When `false`, the engine's
|
||||
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
|
||||
#[tauri::command]
|
||||
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state
|
||||
.autodj_suppress_autocrossfade
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
|
||||
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
|
||||
/// seekability through the probe so its seek path does not panic.
|
||||
random_access: bool,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
@@ -201,6 +204,7 @@ fn open_local_file_input(
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
@@ -330,6 +334,20 @@ async fn open_ranged_or_streaming_input(
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
// On-demand random-access fetcher: lets seeks (Ogg bisection, end-of-
|
||||
// stream probe, forward scrubs) pull arbitrary byte ranges over HTTP
|
||||
// Range instead of blocking until the linear filler reaches the target.
|
||||
// This is what makes seeking work on a still-downloading Opus/Ogg stream
|
||||
// (previously a contained no-op) without forcing a full pre-download.
|
||||
let on_demand = Some(Arc::new(super::stream::OnDemand::new(
|
||||
audio_http_client(state),
|
||||
tokio::runtime::Handle::current(),
|
||||
ctx.url.to_string(),
|
||||
buf.clone(),
|
||||
total,
|
||||
state.generation.clone(),
|
||||
ctx.gen,
|
||||
)));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
@@ -340,11 +358,16 @@ async fn open_ranged_or_streaming_input(
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
on_demand,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
// The on-demand fetcher makes a seek-to-EOF during the probe cheap,
|
||||
// so Ogg can stay seekable through the probe (records its byte range
|
||||
// → real seeking) without forcing a full download.
|
||||
random_access: true,
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ pub async fn audio_preload(
|
||||
duration_hint: f64,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
eager: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -183,12 +184,17 @@ pub async fn audio_preload(
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
|
||||
// when the current track is long-settled) skip the wait so the RAM slot
|
||||
// fills in time for the fade to fire. If the user skips during the wait the
|
||||
// generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
if !eager.unwrap_or(false) {
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -279,10 +279,13 @@ async fn open_preview_decoder(
|
||||
done,
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
// Preview plays a fixed short segment; no user seeking → no need for
|
||||
// the on-demand random-access fetcher.
|
||||
on_demand: None,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
autodj_suppress_arc: Arc<AtomicBool>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
@@ -245,7 +246,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
|
||||
// every advance (gated on the next track being playable). Treat it
|
||||
// like crossfade-off here: only emit `audio:ended` on real source
|
||||
// exhaustion (above) or the watchdog — never the early timer.
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
|
||||
&& !autodj_suppress_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
@@ -335,6 +341,7 @@ mod tests {
|
||||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled: Arc<AtomicBool>,
|
||||
crossfade_secs: Arc<AtomicU32>,
|
||||
autodj_suppress: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
@@ -365,6 +372,7 @@ mod tests {
|
||||
chained: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
autodj_suppress: Arc::new(AtomicBool::new(false)),
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
@@ -384,6 +392,7 @@ mod tests {
|
||||
self.chained.clone(),
|
||||
self.crossfade_enabled.clone(),
|
||||
self.crossfade_secs.clone(),
|
||||
self.autodj_suppress.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
@@ -639,4 +648,34 @@ mod tests {
|
||||
);
|
||||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn autodj_suppress_does_not_fire_crossfade_timer() {
|
||||
// AutoDJ suppression on: even with crossfade enabled and the position
|
||||
// inside the crossfade window, the autonomous timer must NOT emit
|
||||
// audio:ended (JS drives the advance, gated on the next track being
|
||||
// ready). The real end is still reached via source exhaustion.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
h.autodj_suppress.store(true, Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"suppressed AutoDJ must not fire the autonomous crossfade timer"
|
||||
);
|
||||
|
||||
// Source exhausts → audio:ended fires (clean sequential end).
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -184,6 +184,7 @@ pub async fn audio_play_radio(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
|
||||
@@ -90,9 +90,49 @@ pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
let Some(old) = old_sink else {
|
||||
return;
|
||||
};
|
||||
if fade_secs <= 0.0 {
|
||||
old.stop();
|
||||
return;
|
||||
}
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
@@ -107,6 +147,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
@@ -134,21 +175,26 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
|
||||
// Calculate total fade samples from the measured actual_fade_secs.
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
} else if let Some(old) = old_sink {
|
||||
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
|
||||
// so clamp the handoff sink or A blasts over B's fade-in.
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
old.set_volume(0.0);
|
||||
}
|
||||
|
||||
// Keep old sink alive until the fade completes + small margin,
|
||||
// then drop it. No volume stepping needed — the fade-out runs
|
||||
// at sample level inside the audio thread.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
@@ -162,4 +208,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
|
||||
SizedDecoder::new_streaming(
|
||||
Box::new(reader),
|
||||
stream_hint.as_deref(),
|
||||
"track-stream",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
@@ -221,13 +221,18 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
if self.sample_count == 0 {
|
||||
// Seek before any audio has played → this is the initial start-offset
|
||||
// seek (B-head: skip the incoming track's leading silence). Keep the
|
||||
// fade-in (`sample_count` stays 0) so a crossfaded track still rises
|
||||
// in from its trimmed start instead of popping in at full gain.
|
||||
} else if pos.as_millis() < 100 {
|
||||
// Mid-playback seek to the very start: keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
// Mid-playback seek elsewhere (user dragging the seekbar): skip
|
||||
// straight to unity gain so the new position is at full volume.
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
|
||||
@@ -21,9 +21,26 @@ pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
|
||||
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
|
||||
/// Opus, Speex, FLAC-in-Ogg).
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
|
||||
/// construction time, but only when the source reports `is_seekable()` *during
|
||||
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
|
||||
/// `phys_byte_range_end` stays `None` and the first real seek panics with
|
||||
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
|
||||
/// seek to EOF must therefore stay seekable through the probe for Ogg.
|
||||
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"ogg" | "oga" | "ogx" | "opus" | "spx"
|
||||
)
|
||||
}
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use ranged_http::{OnDemand, RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
|
||||
@@ -50,6 +50,127 @@ impl Drop for RangedLoudnessSeedHoldClear {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum bytes fetched per on-demand Range request. A seek often triggers a
|
||||
/// short read; fetching a window amortizes the HTTP round-trip and lets the few
|
||||
/// pages a bisection lands on (and the playback that follows a forward seek) be
|
||||
/// served without a fresh request each time.
|
||||
const OD_FETCH_WINDOW: u64 = 1024 * 1024;
|
||||
/// Forward gap (cursor ahead of the contiguous linear download) above which a
|
||||
/// read is treated as a *seek* and served by an on-demand HTTP Range fetch
|
||||
/// instead of waiting for the linear filler to catch up. Below it we assume
|
||||
/// ordinary read-ahead that the linear download will satisfy shortly, so we do
|
||||
/// not issue redundant range requests during normal (slightly starved) play.
|
||||
const OD_SEEK_GAP: u64 = 512 * 1024;
|
||||
|
||||
/// Random-access companion for [`RangedHttpSource`]: fetches arbitrary byte
|
||||
/// ranges over HTTP `Range` on demand so seeks (which jump the read cursor far
|
||||
/// ahead of the linear download) resolve quickly instead of blocking until the
|
||||
/// linear filler reaches the target.
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer seeks by *bisection* — it reads pages at
|
||||
/// midpoints across the whole byte range, and its probe scans the last pages to
|
||||
/// find the stream-end timestamp. On a purely linear-fill source every such read
|
||||
/// would block until the download caught up (effectively forcing a full
|
||||
/// download before any seek). On-demand range fetches make those reads cheap.
|
||||
pub(crate) struct OnDemand {
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
/// Byte ranges already fetched on demand (sorted/merged not required — N is
|
||||
/// the handful of seek targets per track).
|
||||
filled: Mutex<Vec<(u64, u64)>>,
|
||||
/// Ranges with an in-flight fetch, so a polling read does not respawn them.
|
||||
inflight: Mutex<Vec<(u64, u64)>>,
|
||||
/// Bumped after every completed (success or failure) fetch so the read loop
|
||||
/// can reset its stall deadline while on-demand fetches make progress.
|
||||
progress: AtomicU64,
|
||||
}
|
||||
|
||||
impl OnDemand {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
) -> Self {
|
||||
OnDemand {
|
||||
http,
|
||||
handle,
|
||||
url,
|
||||
buf,
|
||||
total_size,
|
||||
gen_arc,
|
||||
gen,
|
||||
filled: Mutex::new(Vec::new()),
|
||||
inflight: Mutex::new(Vec::new()),
|
||||
progress: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn covers(&self, start: u64, end: u64) -> bool {
|
||||
self.filled
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
fn inflight_covers(&self, start: u64, end: u64) -> bool {
|
||||
self.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
/// Spawn a Range fetch covering at least `[start, end)` (rounded up to
|
||||
/// [`OD_FETCH_WINDOW`]) unless it is already filled or in flight. Returns
|
||||
/// immediately; the caller polls [`OnDemand::covers`] / `progress`.
|
||||
fn request(self: &Arc<Self>, start: u64, end: u64) {
|
||||
if start >= self.total_size {
|
||||
return;
|
||||
}
|
||||
let want_end = end.max(start + OD_FETCH_WINDOW).min(self.total_size);
|
||||
if self.covers(start, want_end) || self.inflight_covers(start, want_end) {
|
||||
return;
|
||||
}
|
||||
self.inflight.lock().unwrap().push((start, want_end));
|
||||
let me = Arc::clone(self);
|
||||
self.handle.spawn(async move {
|
||||
let end_inclusive = want_end.saturating_sub(1);
|
||||
let res = ranged_write_http_range(
|
||||
&me.http,
|
||||
&me.url,
|
||||
&me.buf,
|
||||
start,
|
||||
end_inclusive,
|
||||
me.gen,
|
||||
&me.gen_arc,
|
||||
)
|
||||
.await;
|
||||
if let Ok(written) = res {
|
||||
if written > 0 {
|
||||
me.filled.lock().unwrap().push((start, start + written as u64));
|
||||
}
|
||||
}
|
||||
// Drop the reservation either way so a failed fetch can be retried.
|
||||
me.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|&(s, e)| !(s == start && e == want_end));
|
||||
me.progress.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RangedHttpSource {
|
||||
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
|
||||
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
|
||||
@@ -64,6 +185,10 @@ pub(crate) struct RangedHttpSource {
|
||||
pub(crate) done: Arc<AtomicBool>,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// On-demand random-access fetcher. `None` keeps the legacy linear-only
|
||||
/// behaviour (used by unit tests); production ranged playback sets it so
|
||||
/// seeks resolve via HTTP `Range` instead of blocking on the linear filler.
|
||||
pub(crate) on_demand: Option<Arc<OnDemand>>,
|
||||
}
|
||||
|
||||
impl RangedHttpSource {
|
||||
@@ -78,6 +203,11 @@ impl RangedHttpSource {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(od) = &self.on_demand {
|
||||
if od.covers(start, end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -103,6 +233,11 @@ impl Read for RangedHttpSource {
|
||||
let stall_timeout = Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
let mut deadline = Instant::now() + stall_timeout;
|
||||
let mut last_dl_seen = self.downloaded_to.load(Ordering::Relaxed) as u64;
|
||||
let mut last_od_seen = self
|
||||
.on_demand
|
||||
.as_ref()
|
||||
.map(|od| od.progress.load(Ordering::Relaxed))
|
||||
.unwrap_or(0);
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
@@ -120,6 +255,24 @@ impl Read for RangedHttpSource {
|
||||
last_dl_seen = dl;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
// A read whose cursor is far ahead of the contiguous linear download
|
||||
// is a seek (Ogg bisection midpoint, end-of-stream probe, or a
|
||||
// forward scrub). Serve it from an on-demand HTTP Range fetch rather
|
||||
// than blocking until the linear filler crawls there. While the
|
||||
// download is still running; an aborted download keeps the legacy
|
||||
// partial/EOF behaviour below.
|
||||
if let Some(od) = &self.on_demand {
|
||||
let od_progress = od.progress.load(Ordering::SeqCst);
|
||||
if od_progress != last_od_seen {
|
||||
last_od_seen = od_progress;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
if !self.done.load(Ordering::SeqCst)
|
||||
&& self.pos > dl.saturating_add(OD_SEEK_GAP)
|
||||
{
|
||||
od.request(self.pos, target_end);
|
||||
}
|
||||
}
|
||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||
// beyond a partial download that aborted). Return what we have.
|
||||
if self.done.load(Ordering::SeqCst) {
|
||||
@@ -355,9 +508,14 @@ async fn ranged_write_http_range(
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
if !(response.status() == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| response.status() == reqwest::StatusCode::OK)
|
||||
{
|
||||
// Require 206 for any non-zero offset. A server that ignored the `Range`
|
||||
// header and replied 200 returns the *whole* body from byte 0; writing that
|
||||
// at `start` would corrupt the buffer. A 200 is only safe when we asked from
|
||||
// offset 0 (the body genuinely starts there).
|
||||
let status = response.status();
|
||||
let ok = status == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| (status == reqwest::StatusCode::OK && start == 0);
|
||||
if !ok {
|
||||
return Err(());
|
||||
}
|
||||
let mut written = 0usize;
|
||||
@@ -736,6 +894,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 7,
|
||||
on_demand: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +964,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -835,6 +995,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 2];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -859,6 +1020,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
assert_eq!(src.read(&mut out).unwrap(), 0);
|
||||
@@ -1136,6 +1298,124 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves whatever inclusive byte range the request asks for out of `body`,
|
||||
/// as a 206 — models a server that honours arbitrary `Range` requests.
|
||||
struct RangeResponder {
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Respond for RangeResponder {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let range = req
|
||||
.headers
|
||||
.get(reqwest::header::RANGE.as_str())
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("bytes="))
|
||||
.map(|s| s.to_string());
|
||||
let Some(range) = range else {
|
||||
return ResponseTemplate::new(200).set_body_bytes(self.body.clone());
|
||||
};
|
||||
let mut parts = range.splitn(2, '-');
|
||||
let start: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let end_inclusive: usize = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(self.body.len().saturating_sub(1));
|
||||
let end = (end_inclusive + 1).min(self.body.len());
|
||||
ResponseTemplate::new(206).set_body_bytes(self.body[start..end].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn read_far_ahead_is_served_by_on_demand_range_fetch() {
|
||||
// 4 MiB track; nothing downloaded linearly yet and the download is still
|
||||
// "in progress" (done = false). A read whose cursor sits well past the
|
||||
// linear front must be satisfied by an on-demand Range fetch.
|
||||
let total: usize = 4 * 1024 * 1024;
|
||||
let body: Vec<u8> = (0..total).map(|i| (i % 256) as u8).collect();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(RangeResponder { body: body.clone() })
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let on_demand = Some(Arc::new(OnDemand::new(
|
||||
reqwest::Client::new(),
|
||||
tokio::runtime::Handle::current(),
|
||||
url,
|
||||
buf.clone(),
|
||||
total as u64,
|
||||
gen_arc.clone(),
|
||||
1,
|
||||
)));
|
||||
let mut src = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total as u64,
|
||||
pos: 2 * 1024 * 1024, // 2 MiB — far past the (empty) linear front
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand,
|
||||
};
|
||||
|
||||
// The blocking read polls until the on-demand fetch fills the region.
|
||||
let out = tokio::task::spawn_blocking(move || {
|
||||
let mut out = [0u8; 16];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
(n, out)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.0, 16, "read returns the requested bytes via on-demand fetch");
|
||||
let base = 2 * 1024 * 1024usize;
|
||||
let expected: Vec<u8> = (base..base + 16).map(|i| (i % 256) as u8).collect();
|
||||
assert_eq!(&out.1[..], &expected[..]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ranged_write_http_range_rejects_200_at_nonzero_offset() {
|
||||
// A server that ignores Range and answers 200 with the whole body must
|
||||
// NOT be written at a non-zero offset (would corrupt the buffer).
|
||||
let server = MockServer::start().await;
|
||||
let body = vec![0xCDu8; 4096];
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; 4096]));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let res = ranged_write_http_range(
|
||||
&reqwest::Client::new(),
|
||||
&url,
|
||||
&buf,
|
||||
1024, // non-zero offset
|
||||
2047,
|
||||
1,
|
||||
&gen_arc,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "200 at a non-zero offset must be rejected");
|
||||
assert!(
|
||||
buf.lock().unwrap().iter().all(|&b| b == 0),
|
||||
"buffer must be left untouched on a rejected 200"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn loop_aborts_when_reconnect_returns_non_206() {
|
||||
// Returns 200 first time (partial body), then 200 again (not 206) on the
|
||||
|
||||
@@ -204,6 +204,8 @@ mod tests {
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0)),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
|
||||
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
let mut remap_lookup = if unstable_track_ids {
|
||||
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
|
||||
Some((
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
|
||||
// then do we retarget children to the new id, since
|
||||
// child tables FK→track(server_id, id) and would refuse
|
||||
// an UPDATE pointing at an id that doesn't exist yet.
|
||||
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
|
||||
detect_remap_target_cached(lookup, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let detected_old: Option<String> =
|
||||
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
|
||||
detect_remap_target_cached(by_hash, by_path, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
upsert.execute(params![
|
||||
r.server_id,
|
||||
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
const REMAP_LOOKUP_SQL: &str = r#"
|
||||
// Two single-column lookups instead of one `OR` across `content_hash`
|
||||
// and `server_path`. The combined `OR` form could not use the partial
|
||||
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
|
||||
// applies a partial index when the query's WHERE provably implies the
|
||||
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
|
||||
// the per-branch index plan. The result was a full `track` scan per
|
||||
// incoming row → O(rows × catalog) on large libraries (observed:
|
||||
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
|
||||
// Each statement below repeats the index predicate so the planner picks
|
||||
// the matching partial index (SEARCH, not SCAN); hash wins over path,
|
||||
// matching §6.9's strong-key priority.
|
||||
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND id != ?2
|
||||
AND (
|
||||
(?3 IS NOT NULL AND content_hash = ?3)
|
||||
OR (?4 IS NOT NULL AND server_path = ?4)
|
||||
)
|
||||
AND content_hash IS NOT NULL
|
||||
AND content_hash != ''
|
||||
AND content_hash = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND server_path IS NOT NULL
|
||||
AND server_path != ''
|
||||
AND server_path = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
|
||||
/// non-deleted row with a different id on this server matches the
|
||||
/// incoming row's `content_hash` or `server_path`.
|
||||
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
|
||||
/// key, so it is checked first.
|
||||
fn detect_remap_target_cached(
|
||||
lookup: &mut rusqlite::Statement<'_>,
|
||||
by_hash: &mut rusqlite::Statement<'_>,
|
||||
by_path: &mut rusqlite::Statement<'_>,
|
||||
incoming: &TrackRow,
|
||||
) -> rusqlite::Result<Option<String>> {
|
||||
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
|
||||
// excludes them so the file-tree default never collides.
|
||||
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
|
||||
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
|
||||
if hash.is_none() && path.is_none() {
|
||||
return Ok(None);
|
||||
|
||||
if let Some(hash) = hash {
|
||||
let old = by_hash
|
||||
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
lookup
|
||||
.query_row(
|
||||
params![incoming.server_id, incoming.id, hash, path],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
|
||||
if let Some(path) = path {
|
||||
let old = by_path
|
||||
.query_row(params![incoming.server_id, path, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
|
||||
@@ -1247,6 +1289,48 @@ mod tests {
|
||||
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_lookup_uses_partial_indexes_not_full_scan() {
|
||||
// Regression: the §6.9 remap lookup must hit
|
||||
// idx_track_remap_hash / idx_track_remap_path. The prior
|
||||
// `OR`-based query fell back to a full `track` scan on every
|
||||
// incoming row → O(rows × catalog) stalls on large libraries
|
||||
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let plan = |sql: &str| -> String {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
let rows: rusqlite::Result<Vec<String>> = stmt
|
||||
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
|
||||
.collect();
|
||||
rows
|
||||
})
|
||||
.unwrap()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
|
||||
assert!(
|
||||
hash_plan.contains("idx_track_remap_hash"),
|
||||
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
|
||||
);
|
||||
assert!(
|
||||
!hash_plan.contains("SCAN"),
|
||||
"hash lookup must not full-scan track, got: {hash_plan}"
|
||||
);
|
||||
|
||||
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
|
||||
assert!(
|
||||
path_plan.contains("idx_track_remap_path"),
|
||||
"path lookup must use idx_track_remap_path, got: {path_plan}"
|
||||
);
|
||||
assert!(
|
||||
!path_plan.contains("SCAN"),
|
||||
"path lookup must not full-scan track, got: {path_plan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_is_noop_when_new_id_matches_existing_id() {
|
||||
// Standard delta-sync: same id, same hash. Must not trigger
|
||||
|
||||
+44
-20
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
|
||||
/// it to name the app in taskbar grouping and the SMTC media controls; without it
|
||||
/// the media tile reads "Unknown application". Must match the AppUserModelID the
|
||||
/// installer sets on the Start-menu shortcut so the name/icon resolve.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::core::w;
|
||||
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
|
||||
unsafe {
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Windows: bind this process to an explicit AppUserModelID before any window
|
||||
// or the SMTC media controls are created, so the OS can resolve the app
|
||||
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
|
||||
// Quick-Settings / lock-screen media tile showed "Unknown application").
|
||||
#[cfg(target_os = "windows")]
|
||||
set_app_user_model_id();
|
||||
|
||||
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -502,11 +523,20 @@ pub fn run() {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
match event {
|
||||
MediaControlEvent::Toggle
|
||||
| MediaControlEvent::Play
|
||||
| MediaControlEvent::Pause => {
|
||||
// Keep Play/Pause distinct from Toggle: the OS
|
||||
// (notably macOS on audio-route changes, e.g. a
|
||||
// headphone disconnect) sends an explicit Pause,
|
||||
// and collapsing all three into a toggle would
|
||||
// resume paused playback on the new device (#1094).
|
||||
MediaControlEvent::Toggle => {
|
||||
let _ = app_handle.emit("media:play-pause", ());
|
||||
}
|
||||
MediaControlEvent::Play => {
|
||||
let _ = app_handle.emit("media:play", ());
|
||||
}
|
||||
MediaControlEvent::Pause => {
|
||||
let _ = app_handle.emit("media:pause", ());
|
||||
}
|
||||
MediaControlEvent::Next => {
|
||||
let _ = app_handle.emit("media:next", ());
|
||||
}
|
||||
@@ -599,24 +629,15 @@ pub fn run() {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
api.prevent_close();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS the red close button quits the app entirely.
|
||||
// Route through JS so playback position + Orbit state get
|
||||
// flushed; exit_app on the way back stops the audio engine.
|
||||
let _ = window.emit("app:force-quit", ());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Pause rendering before JS decides whether to hide to tray or exit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
// All platforms: pause rendering, then let JS decide hide-to-tray
|
||||
// vs exit based on the minimizeToTray setting. macOS previously
|
||||
// always force-quit on the red close button, ignoring the setting
|
||||
// (#1103). The tray "Exit" item still emits app:force-quit for an
|
||||
// unconditional quit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
} else if window.label() == "mini" {
|
||||
// Native close on the mini: hide instead of destroying so
|
||||
// state is preserved, and restore the main window.
|
||||
@@ -668,6 +689,7 @@ pub fn run() {
|
||||
cli_publish_server_list,
|
||||
cli_publish_search_results,
|
||||
set_window_decorations,
|
||||
set_back_forward_navigation_gestures,
|
||||
set_linux_webkit_smooth_scrolling,
|
||||
linux_wayland_gpu_font_tuning_active,
|
||||
linux_wayland_text_render_settings_available,
|
||||
@@ -714,6 +736,8 @@ pub fn run() {
|
||||
audio::preview::audio_preview_set_volume,
|
||||
audio::mix_commands::audio_set_crossfade,
|
||||
audio::mix_commands::audio_set_gapless,
|
||||
audio::mix_commands::audio_begin_outgoing_fade,
|
||||
audio::mix_commands::audio_set_autodj_suppress,
|
||||
audio::mix_commands::audio_set_normalization,
|
||||
audio::device_commands::audio_list_devices,
|
||||
audio::device_commands::audio_canonicalize_selected_device,
|
||||
|
||||
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
|
||||
let duration = duration_secs.map(Duration::from_secs_f64);
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
|
||||
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
|
||||
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
|
||||
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
|
||||
// does not handle WebP, even with the Store WebP extension installed).
|
||||
// Transcode local WebP covers to PNG for the OS media controls; macOS
|
||||
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
|
||||
let cover_url = smtc_cover_url(cover_url);
|
||||
|
||||
ctrl.set_metadata(MediaMetadata {
|
||||
title: title.as_deref(),
|
||||
artist: artist.as_deref(),
|
||||
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
|
||||
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
|
||||
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
|
||||
/// non-`file://`/non-WebP URL pass through unchanged.
|
||||
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(url) = cover_url.as_deref() {
|
||||
if let Some(path) = url.strip_prefix("file://") {
|
||||
let is_webp = std::path::Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
|
||||
if is_webp {
|
||||
match webp_file_to_temp_png(path) {
|
||||
Ok(png) => return Some(format!("file://{png}")),
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cover_url
|
||||
}
|
||||
|
||||
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
|
||||
/// re-encode it as a PNG in the temp dir, returning the native path. A single
|
||||
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
|
||||
/// and the controls mutex serializes calls so it is never written concurrently.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
|
||||
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
|
||||
let decoded = webp::Decoder::new(&bytes)
|
||||
.decode()
|
||||
.ok_or_else(|| "WebP decode returned None".to_string())?;
|
||||
let img = decoded.to_image();
|
||||
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
|
||||
img.save_with_format(&out, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(out.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn mpris_set_playback(
|
||||
controls: tauri::State<MprisControls>,
|
||||
|
||||
@@ -22,7 +22,8 @@ pub(crate) use core::{
|
||||
pub(crate) use perf::performance_cpu_snapshot;
|
||||
pub(crate) use platform::{
|
||||
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
|
||||
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
|
||||
set_back_forward_navigation_gestures, set_linux_wayland_text_render_profile,
|
||||
set_linux_webkit_smooth_scrolling, set_window_decorations,
|
||||
theme_animation_risk,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -208,6 +208,85 @@ pub(crate) fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, en
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// WebKitGTK / WKWebView / WebView2: two-finger horizontal swipe for history back/forward.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn webview_apply_back_forward_navigation_gestures(
|
||||
win: &tauri::WebviewWindow,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
win.with_webview(move |platform| {
|
||||
use webkit2gtk::{SettingsExt, WebViewExt};
|
||||
if let Some(settings) = platform.inner().settings() {
|
||||
settings.set_enable_back_forward_navigation_gestures(enabled);
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn webview_apply_back_forward_navigation_gestures(
|
||||
win: &tauri::WebviewWindow,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
win.with_webview(move |platform| {
|
||||
use objc2_web_kit::WKWebView;
|
||||
let ptr = platform.inner();
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let webview = &*(ptr as *const WKWebView);
|
||||
webview.setAllowsBackForwardNavigationGestures(enabled);
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn webview_apply_back_forward_navigation_gestures(
|
||||
win: &tauri::WebviewWindow,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::*;
|
||||
use windows::core::Interface;
|
||||
win.with_webview(move |platform| {
|
||||
let controller = platform.controller();
|
||||
unsafe {
|
||||
if let Ok(webview) = controller.CoreWebView2() {
|
||||
if let Ok(settings) = webview.Settings() {
|
||||
if let Ok(settings6) = settings.cast::<ICoreWebView2Settings6>() {
|
||||
let _ = settings6.SetIsSwipeNavigationEnabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
|
||||
pub(crate) fn webview_apply_back_forward_navigation_gestures(
|
||||
_win: &tauri::WebviewWindow,
|
||||
_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called from the frontend settings toggle; applies to every open webview window.
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_back_forward_navigation_gestures(
|
||||
enabled: bool,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
for label in ["main", "mini"] {
|
||||
if let Some(win) = app_handle.get_webview_window(label) {
|
||||
webview_apply_back_forward_navigation_gestures(&win, enabled)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
@@ -415,7 +415,7 @@ pub(crate) fn toggle_tray_icon(
|
||||
|
||||
pub(crate) use crate::audio::stop_audio_engine;
|
||||
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Niri, Sway, i3,
|
||||
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
|
||||
/// set by the compositor / DE.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -423,6 +423,7 @@ pub(crate) fn is_tiling_wm() -> bool {
|
||||
// Direct compositor signatures (most reliable).
|
||||
let direct = [
|
||||
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
|
||||
"NIRI_SOCKET", // Niri
|
||||
"SWAYSOCK", // Sway
|
||||
"I3SOCK", // i3
|
||||
]
|
||||
@@ -437,7 +438,7 @@ pub(crate) fn is_tiling_wm() -> bool {
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop = desktop.to_lowercase();
|
||||
let tiling_wms = [
|
||||
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"hyprland", "niri", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
|
||||
];
|
||||
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
//! strict.
|
||||
#![cfg_attr(debug_assertions, allow(dead_code))]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use std::sync::atomic::{AtomicIsize, AtomicU32, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows::{
|
||||
core::w,
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::Com::{
|
||||
@@ -29,7 +30,7 @@ use windows::{
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||
WM_COMMAND, WM_NCDESTROY,
|
||||
RegisterWindowMessageW, WM_COMMAND, WM_NCDESTROY,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -61,6 +62,10 @@ static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// Registered window-message id for the shell's "TaskbarButtonCreated"
|
||||
// broadcast (0 until registered in `init`).
|
||||
static TASKBAR_BUTTON_CREATED_MSG: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
// ── ICO resource loader ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
||||
@@ -174,6 +179,15 @@ unsafe extern "system" fn subclass_proc(
|
||||
_uid: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
// The shell sends this once the taskbar button exists (on the first window
|
||||
// show) and again after an explorer.exe restart — the only safe moment to
|
||||
// (re)add the thumbnail buttons. See `add_thumb_buttons`.
|
||||
let tb_created = TASKBAR_BUTTON_CREATED_MSG.load(Ordering::SeqCst);
|
||||
if tb_created != 0 && msg == tb_created {
|
||||
add_thumb_buttons();
|
||||
return DefSubclassProc(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
if msg == WM_COMMAND {
|
||||
let hi = (wparam.0 >> 16) as u32;
|
||||
let lo = (wparam.0 & 0xFFFF) as u32;
|
||||
@@ -211,6 +225,43 @@ unsafe extern "system" fn subclass_proc(
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
// ── Thumbnail-button (re)attach ────────────────────────────────────────────────
|
||||
|
||||
/// (Re)add the three media buttons to the taskbar thumbnail toolbar.
|
||||
///
|
||||
/// Must run only after the shell has created the window's taskbar button and
|
||||
/// broadcast `TaskbarButtonCreated`. Calling `ThumbBarAddButtons` before that
|
||||
/// point returns `S_OK` but silently adds nothing — which is why the buttons
|
||||
/// disappeared once the main window started hidden/deferred (PR #1030 moved the
|
||||
/// window to `visible: false` with a deferred show, so at `setup` time the
|
||||
/// taskbar button does not exist yet).
|
||||
unsafe fn add_thumb_buttons() {
|
||||
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
||||
let hwnd_raw = HWND_VAL.load(Ordering::SeqCst);
|
||||
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
||||
|
||||
let h_prev = HICON_PREV.load(Ordering::SeqCst);
|
||||
let h_play = HICON_PLAY.load(Ordering::SeqCst);
|
||||
let h_next = HICON_NEXT.load(Ordering::SeqCst);
|
||||
if h_prev == 0 || h_play == 0 || h_next == 0 { return; }
|
||||
|
||||
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
// Harmless on an already-initialised object; required again after an
|
||||
// explorer restart recreates the taskbar button.
|
||||
let _ = taskbar.HrInit();
|
||||
|
||||
let buttons = make_buttons(
|
||||
HICON(h_prev as *mut _),
|
||||
HICON(h_play as *mut _),
|
||||
HICON(h_next as *mut _),
|
||||
);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
|
||||
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public init ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
@@ -242,11 +293,12 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
||||
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
||||
|
||||
let buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
|
||||
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Register the shell's "TaskbarButtonCreated" message. The buttons are
|
||||
// added from the subclass proc when it fires (after the first window
|
||||
// show), because the deferred/hidden main window means the taskbar
|
||||
// button does not exist yet at this point.
|
||||
let tb_msg = RegisterWindowMessageW(w!("TaskbarButtonCreated"));
|
||||
TASKBAR_BUTTON_CREATED_MSG.store(tb_msg, Ordering::SeqCst);
|
||||
|
||||
let raw = Box::into_raw(Box::new(taskbar));
|
||||
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
|
||||
export type PlayQueueResult = { current?: string; position?: number; songs: SubsonicSong[] };
|
||||
|
||||
function parsePlayQueueResponse(
|
||||
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
|
||||
): PlayQueueResult {
|
||||
const pq = data.playQueue;
|
||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
||||
}
|
||||
|
||||
export async function getPlayQueue(): Promise<PlayQueueResult> {
|
||||
try {
|
||||
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
|
||||
const pq = data.playQueue;
|
||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayQueueForServer(serverId: string): Promise<PlayQueueResult> {
|
||||
if (!serverId) return { songs: [] };
|
||||
try {
|
||||
const data = await apiForServer<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getPlayQueue.view',
|
||||
);
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilter
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
@@ -102,6 +103,7 @@ export function AppShell() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
useIdlePlayQueuePull(connStatus);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { usePlayQueueSyncLedState } from '../hooks/usePlayQueueSyncLedState';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/server/switchActiveServer';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
@@ -21,6 +23,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
const navigate = useNavigate();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const {
|
||||
ledVariant,
|
||||
localQueueSyncPaused,
|
||||
queueHandoffReason,
|
||||
pullInFlight,
|
||||
syncRingVisible,
|
||||
pullFromActiveServer,
|
||||
} = usePlayQueueSyncLedState(status);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
||||
@@ -51,9 +61,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (hostRef.current?.contains(t)) return;
|
||||
if (menuPanelRef.current?.contains(t)) return;
|
||||
const target = e.target as Node;
|
||||
if (hostRef.current?.contains(target)) return;
|
||||
if (menuPanelRef.current?.contains(target)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -72,7 +82,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
navigate('/settings', { state: { tab: 'servers' } });
|
||||
};
|
||||
|
||||
const onTriggerClick = () => {
|
||||
const onMetaClick = () => {
|
||||
if (!multi) {
|
||||
goServerSettings();
|
||||
return;
|
||||
@@ -80,6 +90,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
setMenuOpen(o => !o);
|
||||
};
|
||||
|
||||
const onSyncClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (status !== 'connected') return;
|
||||
void pullFromActiveServer();
|
||||
};
|
||||
|
||||
const onPickServer = async (srv: ServerProfile) => {
|
||||
if (srv.id === activeServerId) {
|
||||
setMenuOpen(false);
|
||||
@@ -97,28 +113,46 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
};
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip = multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
const tooltip = pullInFlight
|
||||
? t('connection.queuePulling')
|
||||
: ledVariant === 'queue-handoff'
|
||||
? localQueueSyncPaused && !queueHandoffReason
|
||||
? t('connection.queueLocalEditHint')
|
||||
: t('connection.queuePullHint', { server: serverName })
|
||||
: ledVariant === 'connected'
|
||||
? t('connection.queueSynced')
|
||||
: multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator-host" ref={hostRef}>
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onTriggerClick}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
role={multi ? 'button' : undefined}
|
||||
aria-haspopup={multi ? 'menu' : undefined}
|
||||
aria-expanded={multi ? menuOpen : undefined}
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<div className="connection-indicator">
|
||||
<button
|
||||
type="button"
|
||||
className={`connection-sync-btn${syncRingVisible ? ' connection-sync-btn--visible' : ''}${pullInFlight ? ' connection-sync-btn--busy' : ''}`}
|
||||
onClick={onSyncClick}
|
||||
disabled={status !== 'connected' || pullInFlight}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('connection.queuePullAria')}
|
||||
>
|
||||
<RefreshCw size={13} className="connection-sync-icon" aria-hidden />
|
||||
<div className={`connection-led connection-led--${ledVariant}`} />
|
||||
</button>
|
||||
<div
|
||||
className="connection-meta connection-meta--clickable"
|
||||
onClick={onMetaClick}
|
||||
data-tooltip={multi ? t('connection.switchServerHint') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
role={multi ? 'button' : undefined}
|
||||
aria-haspopup={multi ? 'menu' : undefined}
|
||||
aria-expanded={multi ? menuOpen : undefined}
|
||||
>
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||
|
||||
@@ -46,6 +46,7 @@ beforeEach(() => {
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('set_window_always_on_top', () => undefined);
|
||||
onInvoke('set_linux_webkit_smooth_scrolling', () => undefined);
|
||||
onInvoke('set_back_forward_navigation_gestures', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { MiniControls } from './miniPlayer/MiniControls';
|
||||
import { MiniToolbar } from './miniPlayer/MiniToolbar';
|
||||
import { MiniQueue } from './miniPlayer/MiniQueue';
|
||||
import { useMiniVolumePopover } from '../hooks/useMiniVolumePopover';
|
||||
import { useMiniCrossfadePopover } from '../hooks/useMiniCrossfadePopover';
|
||||
import { useMiniQueueDrag } from '../hooks/useMiniQueueDrag';
|
||||
import { useMiniSync } from '../hooks/useMiniSync';
|
||||
import { useMiniWindowSetup } from '../hooks/useMiniWindowSetup';
|
||||
@@ -49,6 +50,7 @@ export default function MiniPlayer() {
|
||||
return registerQueueDragHitTest(hitTest);
|
||||
}, [queueOpen]);
|
||||
const { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover();
|
||||
const { crossfadeOpen, setCrossfadeOpen, crossfadePopStyle, crossfadeBtnRef, crossfadePopRef } = useMiniCrossfadePopover();
|
||||
|
||||
const {
|
||||
isReorderDrag, psyDragFromIdxRef, dropTarget, setDropTarget, dropTargetRef, startDrag,
|
||||
@@ -169,6 +171,11 @@ export default function MiniPlayer() {
|
||||
volumePopStyle={volumePopStyle}
|
||||
handleVolumeChange={handleVolumeChange}
|
||||
toggleMute={toggleMute}
|
||||
crossfadeOpen={crossfadeOpen}
|
||||
setCrossfadeOpen={setCrossfadeOpen}
|
||||
crossfadeBtnRef={crossfadeBtnRef}
|
||||
crossfadePopRef={crossfadePopRef}
|
||||
crossfadePopStyle={crossfadePopStyle}
|
||||
queueOpen={queueOpen}
|
||||
toggleQueue={toggleQueue}
|
||||
t={t}
|
||||
|
||||
@@ -23,13 +23,14 @@ export default function NowPlayingDropdown() {
|
||||
const triggerWrapRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
// Wall-clock baseline for the last poll: between polls (every 10 s) we
|
||||
// extrapolate the position of `playing` entries locally so the progress bar
|
||||
// glides instead of snapping. The server already extrapolates positionMs at
|
||||
// fetch time, so this just continues from there using the reported speed.
|
||||
// Wall-clock baseline for the last poll: between polls we extrapolate the
|
||||
// position of `playing` entries locally so the progress bar glides instead
|
||||
// of snapping. The server already extrapolates positionMs at fetch time.
|
||||
const fetchedAtRef = useRef(0);
|
||||
const [, forceTick] = useState(0);
|
||||
const PANEL_WIDTH = 340;
|
||||
const LIVE_POLL_OPEN_MS = 10_000;
|
||||
const LIVE_POLL_BACKGROUND_MS = 30_000;
|
||||
|
||||
const formatClock = (totalSec: number) => {
|
||||
const s = Math.max(0, Math.floor(totalSec));
|
||||
@@ -61,8 +62,8 @@ export default function NowPlayingDropdown() {
|
||||
setPanelPos({ top, left });
|
||||
}, []);
|
||||
|
||||
const fetchNowPlaying = async () => {
|
||||
setLoading(true);
|
||||
const fetchNowPlaying = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) setLoading(true);
|
||||
try {
|
||||
const data = await getNowPlaying();
|
||||
fetchedAtRef.current = Date.now();
|
||||
@@ -70,9 +71,9 @@ export default function NowPlayingDropdown() {
|
||||
} catch (e) {
|
||||
console.error('Failed to load Now Playing', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!opts?.silent) setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setSpinning(true);
|
||||
@@ -81,18 +82,30 @@ export default function NowPlayingDropdown() {
|
||||
});
|
||||
};
|
||||
|
||||
// Poll only while the dropdown is open AND the page is visible.
|
||||
// Poll while the page is visible: every 30 s for the header badge, every 10 s
|
||||
// while the popover is open so the list stays fresher.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchNowPlaying();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') fetchNowPlaying();
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [isOpen]);
|
||||
const poll = (silent: boolean) => {
|
||||
if (document.visibilityState === 'visible') void fetchNowPlaying({ silent });
|
||||
};
|
||||
|
||||
poll(!isOpen);
|
||||
const intervalMs = isOpen ? LIVE_POLL_OPEN_MS : LIVE_POLL_BACKGROUND_MS;
|
||||
const id = setInterval(() => poll(true), intervalMs);
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') poll(true);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [isOpen, fetchNowPlaying]);
|
||||
|
||||
// Re-render once per second while a `playing` entry exposes a position, so the
|
||||
// locally-extrapolated bar advances smoothly between the 10 s polls.
|
||||
// locally-extrapolated bar advances smoothly between polls.
|
||||
const hasLivePosition = nowPlaying.some(
|
||||
e => e.state === 'playing' && typeof e.positionMs === 'number',
|
||||
);
|
||||
|
||||
@@ -144,6 +144,25 @@ describe('PlayerBar — control wiring', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerBar — current track context menu', () => {
|
||||
it('right-clicking the track name opens a song-scoped menu for the current track (#1116)', () => {
|
||||
const track = makeTrack({ id: 'cur', albumId: 'alb-1' });
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
const spy = vi.spyOn(usePlayerStore.getState(), 'openContextMenu');
|
||||
|
||||
const { container } = renderWithProviders(<PlayerBar />);
|
||||
const trackName = container.querySelector('.player-track-name');
|
||||
expect(trackName).not.toBeNull();
|
||||
fireEvent.contextMenu(trackName!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [, , item, type, , , , , pin] = spy.mock.calls[0];
|
||||
expect(item).toBe(track);
|
||||
expect(type).toBe('song');
|
||||
expect(pin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerBar — empty state (no current track)', () => {
|
||||
it('still renders the region landmark when no track is loaded', () => {
|
||||
usePlayerStore.setState({ currentTrack: null, isPlaying: false });
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
}));
|
||||
|
||||
import QueuePanel from './QueuePanel';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -164,15 +165,28 @@ describe('QueuePanel — display mode', () => {
|
||||
});
|
||||
|
||||
describe('QueuePanel — toolbar', () => {
|
||||
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
|
||||
it('exposes Shuffle / Playlist / Share Queue / Clear / AutoDJ via aria-label', () => {
|
||||
const tracks = makeTracks(3);
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
|
||||
expect(getByLabelText('Save Playlist')).toBeInTheDocument();
|
||||
expect(getByLabelText('Load Playlist')).toBeInTheDocument();
|
||||
expect(getByLabelText('Playlist')).toBeInTheDocument();
|
||||
expect(getByLabelText('Copy queue share link')).toBeInTheDocument();
|
||||
expect(getByLabelText('Clear queue')).toBeInTheDocument();
|
||||
expect(getByLabelText('AutoDJ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save and Load live inside the Playlist submenu, not directly on the toolbar', () => {
|
||||
const tracks = makeTracks(3);
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { getByLabelText, container } = renderWithProviders(<QueuePanel />);
|
||||
// The submenu is closed initially.
|
||||
expect(container.querySelector('.queue-menu')).toBeNull();
|
||||
fireEvent.click(getByLabelText('Playlist'));
|
||||
const menu = container.querySelector('.queue-menu');
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu?.textContent).toContain('Save Playlist');
|
||||
expect(menu?.textContent).toContain('Load Playlist');
|
||||
});
|
||||
|
||||
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
|
||||
|
||||
@@ -106,11 +106,10 @@ function QueuePanelHostOrSolo() {
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const crossfadeTrimSilence = useAuthStore(s => s.crossfadeTrimSilence);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
||||
|
||||
@@ -335,9 +334,8 @@ function QueuePanelHostOrSolo() {
|
||||
handleCopyQueueShare={handleCopyQueueShare}
|
||||
handleClear={handleClear}
|
||||
gaplessEnabled={gaplessEnabled}
|
||||
setGaplessEnabled={setGaplessEnabled}
|
||||
crossfadeEnabled={crossfadeEnabled}
|
||||
setCrossfadeEnabled={setCrossfadeEnabled}
|
||||
crossfadeTrimSilence={crossfadeTrimSilence}
|
||||
crossfadeSecs={crossfadeSecs}
|
||||
setCrossfadeSecs={setCrossfadeSecs}
|
||||
infiniteQueueEnabled={infiniteQueueEnabled}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Folder, FolderMinus, Plus } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
|
||||
interface Props {
|
||||
playlistId: string;
|
||||
onDone: () => void;
|
||||
triggerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submenu for assigning a single playlist to a (local) folder. Mirrors the
|
||||
* "Add to playlist" submenu's layout/positioning so it shares the context-menu
|
||||
* hover machinery. Folder assignment is purely local state, so it stays
|
||||
* available offline.
|
||||
*/
|
||||
export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
const [flipLeft, setFlipLeft] = useState(false);
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const bucket =
|
||||
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
|
||||
const createFolder = usePlaylistFolderStore(s => s.createFolder);
|
||||
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
|
||||
|
||||
const currentFolderId = bucket.assignments[playlistId];
|
||||
const folders = useMemo(
|
||||
() => [...bucket.folders].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)),
|
||||
[bucket.folders],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (subRef.current) {
|
||||
const rect = subRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||
if (rect.bottom > window.innerHeight - 8) setFlipUp(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating && newNameRef.current) newNameRef.current.focus();
|
||||
}, [creating]);
|
||||
|
||||
const assign = (folderId: string | null) => {
|
||||
if (!serverId) return;
|
||||
setPlaylistFolder(serverId, playlistId, folderId);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = newName.trim();
|
||||
if (!name || !serverId) return;
|
||||
const id = createFolder(serverId, name);
|
||||
setPlaylistFolder(serverId, playlistId, id);
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = flipLeft
|
||||
? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
|
||||
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
|
||||
|
||||
return (
|
||||
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||
{!creating ? (
|
||||
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
|
||||
<Plus size={13} /> {t('playlists.folders.newFolder')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.folders.namePlaceholder')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{currentFolderId != null && (
|
||||
<div className="context-menu-item" onClick={() => assign(null)}>
|
||||
<FolderMinus size={13} /> {t('playlists.folders.removeFromFolder')}
|
||||
</div>
|
||||
)}
|
||||
{folders.map(f => (
|
||||
<div key={f.id} className="context-menu-item" onClick={() => assign(f.id)}>
|
||||
<Folder size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{f.name}</span>
|
||||
{currentFolderId === f.id && <Check size={13} style={{ marginLeft: 'auto' }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ChevronRight, ListMusic, Trash2 } from 'lucide-react';
|
||||
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from './PlaylistToPlaylistSubmenus';
|
||||
import MoveToFolderSubmenu from './MoveToFolderSubmenu';
|
||||
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
||||
|
||||
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
@@ -48,6 +49,19 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Folder assignment is local-only state, so it stays available offline. */}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`folder:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`folder:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<FolderTree size={14} /> {t('playlists.folders.moveToFolder')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` && (
|
||||
<MoveToFolderSubmenu playlistId={playlist.id} triggerId={`folder:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
|
||||
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { MiniSyncPayload } from '../../utils/miniPlayerBridge';
|
||||
import { getTransitionMode } from '../../utils/playback/playbackTransition';
|
||||
|
||||
interface Props {
|
||||
state: MiniSyncPayload;
|
||||
@@ -15,6 +16,11 @@ interface Props {
|
||||
volumePopStyle: React.CSSProperties;
|
||||
handleVolumeChange: (v: number) => void;
|
||||
toggleMute: () => void;
|
||||
crossfadeOpen: boolean;
|
||||
setCrossfadeOpen: (updater: boolean | ((v: boolean) => boolean)) => void;
|
||||
crossfadeBtnRef: React.RefObject<HTMLButtonElement | null>;
|
||||
crossfadePopRef: React.RefObject<HTMLDivElement | null>;
|
||||
crossfadePopStyle: React.CSSProperties;
|
||||
queueOpen: boolean;
|
||||
toggleQueue: () => void;
|
||||
t: TFunction;
|
||||
@@ -22,8 +28,12 @@ interface Props {
|
||||
|
||||
export function MiniToolbar({
|
||||
state, volume, volumeOpen, setVolumeOpen, volumeBtnRef, volumePopRef, volumePopStyle,
|
||||
handleVolumeChange, toggleMute, queueOpen, toggleQueue, t,
|
||||
handleVolumeChange, toggleMute,
|
||||
crossfadeOpen, setCrossfadeOpen, crossfadeBtnRef, crossfadePopRef, crossfadePopStyle,
|
||||
queueOpen, toggleQueue, t,
|
||||
}: Props) {
|
||||
const mode = getTransitionMode(state);
|
||||
|
||||
return (
|
||||
<div className="mini-player__toolbar" data-tauri-drag-region="false">
|
||||
<div className="mini-player__volume-wrap">
|
||||
@@ -101,8 +111,8 @@ export function MiniToolbar({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
|
||||
className={`mini-player__tool${mode === 'gapless' ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-transition-mode', { value: mode === 'gapless' ? 'none' : 'gapless' }).catch(() => {})}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
@@ -111,15 +121,56 @@ export function MiniToolbar({
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
type="button"
|
||||
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
|
||||
className={`mini-player__tool${mode === 'crossfade' || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-transition-mode', { value: mode === 'crossfade' ? 'none' : 'crossfade' }).catch(() => {})}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCrossfadeOpen(v => !v); }}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{crossfadeOpen && createPortal(
|
||||
<div
|
||||
ref={crossfadePopRef}
|
||||
className="mini-player__crossfade-popover"
|
||||
style={crossfadePopStyle}
|
||||
data-tauri-drag-region="false"
|
||||
>
|
||||
<div className="mini-player__crossfade-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="mini-player__crossfade-value">{(state.crossfadeSecs ?? 3).toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.crossfadeSecs ?? 3}
|
||||
onChange={e => {
|
||||
emit('mini:set-crossfade-secs', { value: parseFloat(e.target.value) }).catch(() => {});
|
||||
emit('mini:set-transition-mode', { value: 'crossfade' }).catch(() => {});
|
||||
}}
|
||||
className="mini-player__crossfade-slider"
|
||||
aria-label={t('queue.crossfade')}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${mode === 'autodj' ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-transition-mode', { value: mode === 'autodj' ? 'none' : 'autodj' }).catch(() => {})}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.autoDj')}
|
||||
aria-label={t('queue.autoDj')}
|
||||
>
|
||||
<Blend size={13} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { queueSongRating } from '../../store/pendingStarSync';
|
||||
import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||
import type { InternetRadioStation, SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||
import type { PlayerState, Track } from '../../store/playerStoreTypes';
|
||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||
import type { PreviewingTrack } from '../../store/previewStore';
|
||||
@@ -137,19 +137,14 @@ export function PlayerTrackInfo({
|
||||
className="player-track-name"
|
||||
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
|
||||
onContextMenu={!isRadio && !showPreviewMeta && currentTrack
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
const album: SubsonicAlbum = {
|
||||
id: currentTrack.albumId!,
|
||||
name: currentTrack.album,
|
||||
artist: currentTrack.artist,
|
||||
artistId: currentTrack.artistId ?? '',
|
||||
coverArt: currentTrack.coverArt,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album', undefined, undefined, undefined, undefined, true);
|
||||
// The player bar represents the current song, so its menu is
|
||||
// song-scoped (e.g. "Add to playlist" adds this track, not the
|
||||
// whole album). pinToPlaybackServer: the track plays from the
|
||||
// playback server, which may differ from the active one.
|
||||
openContextMenu(e.clientX, e.clientY, currentTrack, 'song', undefined, undefined, undefined, undefined, true);
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
|
||||
import { Blend, Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { useAutodjTransitionUi } from '../../store/autodjTransitionUi';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import PlaybackScheduleBadge from '../PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '../../hooks/usePlaybackDelayPress';
|
||||
@@ -32,6 +33,10 @@ export function PlayerTransportControls({
|
||||
isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode,
|
||||
playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t,
|
||||
}: Props) {
|
||||
const autodjPhase = useAutodjTransitionUi(s => s.phase);
|
||||
const showAutodjTransition =
|
||||
isPlaying && !isPreviewing && scheduleRemaining == null && autodjPhase === 'mixing';
|
||||
|
||||
return (
|
||||
<div className="player-buttons" ref={transportAnchorRef}>
|
||||
<button
|
||||
@@ -68,7 +73,11 @@ export function PlayerTransportControls({
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
className={`player-btn player-btn-primary${isPreviewing ? ' is-previewing' : ''}`}
|
||||
className={[
|
||||
'player-btn player-btn-primary',
|
||||
isPreviewing ? 'is-previewing' : '',
|
||||
showAutodjTransition ? 'is-autodj-transition' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
type="button"
|
||||
{...playPauseBind}
|
||||
onClick={isPreviewing
|
||||
@@ -80,8 +89,16 @@ export function PlayerTransportControls({
|
||||
invoke('audio_preview_stop').catch(() => {});
|
||||
})
|
||||
: playPauseBind.onClick}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
|
||||
aria-label={isPreviewing
|
||||
? t('playlists.previewStop')
|
||||
: showAutodjTransition
|
||||
? t('player.autoDjMixing')
|
||||
: isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPreviewing
|
||||
? t('playlists.previewStop')
|
||||
: showAutodjTransition
|
||||
? t('player.autoDjMixing')
|
||||
: isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{scheduleRemaining != null ? (
|
||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
|
||||
@@ -92,6 +109,8 @@ export function PlayerTransportControls({
|
||||
</span>
|
||||
) : isPreviewing ? (
|
||||
<Square size={16} fill="currentColor" strokeWidth={0} />
|
||||
) : showAutodjTransition ? (
|
||||
<Blend size={22} className="player-btn-autodj-icon" strokeWidth={2.25} />
|
||||
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
displayPlaylistName, isSmartPlaylistName, type PendingSmartPlaylist,
|
||||
} from '../../utils/playlist/playlistsSmart';
|
||||
import { formatHumanHoursMinutes } from '../../utils/format/formatHumanDuration';
|
||||
import { useDragSource } from '../../contexts/DragDropContext';
|
||||
import { PlaylistCardMainCover, PlaylistSmartCoverCell } from './PlaylistCoverImages';
|
||||
|
||||
interface Props {
|
||||
pl: SubsonicPlaylist;
|
||||
selectionMode: boolean;
|
||||
/** Enables dragging the card onto a folder drop target (folder view only). */
|
||||
draggable?: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedPlaylists: SubsonicPlaylist[];
|
||||
toggleSelect: (id: string, opts?: { shiftKey?: boolean }) => void;
|
||||
@@ -30,7 +33,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PlaylistCard({
|
||||
pl, selectionMode, selectedIds, selectedPlaylists,
|
||||
pl, selectionMode, draggable, selectedIds, selectedPlaylists,
|
||||
toggleSelect, isPlaylistDeletable,
|
||||
deleteConfirmId, setDeleteConfirmId,
|
||||
handleOpenSmartEditor, handleDelete, handlePlay, playingId,
|
||||
@@ -40,10 +43,16 @@ export default function PlaylistCard({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const dragHandlers = useDragSource(() => ({
|
||||
data: JSON.stringify({ type: 'playlist', id: pl.id }),
|
||||
label: displayPlaylistName(pl.name),
|
||||
}));
|
||||
const dragEnabled = Boolean(draggable) && !selectionMode;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' album-card--selected' : ''}`}
|
||||
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' album-card--selected' : ''}${dragEnabled ? ' album-card--draggable' : ''}`}
|
||||
{...(dragEnabled ? dragHandlers : {})}
|
||||
onClick={(e) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(pl.id, { shiftKey: e.shiftKey });
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import PlaylistFolderSection from './PlaylistFolderSection';
|
||||
import { usePlaylistFolderStore } from '@/store/playlistFolderStore';
|
||||
import type { PlaylistFolder } from '@/utils/playlist/playlistFolders';
|
||||
|
||||
const store = () => usePlaylistFolderStore.getState();
|
||||
const assignments = (serverId: string) => store().byServer[serverId]?.assignments ?? {};
|
||||
|
||||
beforeEach(() => {
|
||||
usePlaylistFolderStore.setState({ byServer: {}, groupView: true });
|
||||
});
|
||||
|
||||
/** Simulate the shared mouse-DnD system releasing a playlist over an element. */
|
||||
function dropPlaylist(el: Element, playlistId: string) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('psy-drop', {
|
||||
bubbles: true,
|
||||
detail: { data: JSON.stringify({ type: 'playlist', id: playlistId }) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('PlaylistFolderSection — drop target', () => {
|
||||
it('files a dropped playlist into the folder', () => {
|
||||
const id = store().createFolder('s1', 'Rock');
|
||||
const folder: PlaylistFolder = { id, name: 'Rock', order: 0, collapsed: false };
|
||||
const { container } = renderWithProviders(
|
||||
<PlaylistFolderSection
|
||||
serverId="s1"
|
||||
folder={folder}
|
||||
items={[]}
|
||||
renderCard={() => null}
|
||||
disableVirtualization
|
||||
/>,
|
||||
);
|
||||
dropPlaylist(container.querySelector('.playlist-folder')!, 'p1');
|
||||
expect(assignments('s1').p1).toBe(id);
|
||||
});
|
||||
|
||||
it('unfiles a dropped playlist in the ungrouped section', () => {
|
||||
const id = store().createFolder('s1', 'Rock');
|
||||
store().setPlaylistFolder('s1', 'p1', id);
|
||||
const { container } = renderWithProviders(
|
||||
<PlaylistFolderSection
|
||||
serverId="s1"
|
||||
folder={null}
|
||||
items={[]}
|
||||
renderCard={() => null}
|
||||
disableVirtualization
|
||||
/>,
|
||||
);
|
||||
dropPlaylist(container.querySelector('.playlist-folder--ungrouped')!, 'p1');
|
||||
expect(assignments('s1').p1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, Folder, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
import type { PlaylistFolder } from '../../utils/playlist/playlistFolders';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { VirtualCardGrid } from '../VirtualCardGrid';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
/** The folder this section renders, or null for the ungrouped remainder. */
|
||||
folder: PlaylistFolder | null;
|
||||
items: SubsonicPlaylist[];
|
||||
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
|
||||
disableVirtualization: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One folder section (header + card grid), or the ungrouped remainder when
|
||||
* `folder` is null. The header is a `psy-drop` target: dropping a dragged
|
||||
* playlist card here assigns it to this folder (or unfiles it for ungrouped).
|
||||
* Uses the shared mouse-based DnD system (HTML5 DnD is unusable in WebKitGTK).
|
||||
*/
|
||||
export default function PlaylistFolderSection({
|
||||
serverId, folder, items, renderCard, disableVirtualization,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { isDragging } = useDragDrop();
|
||||
const renameFolder = usePlaylistFolderStore(s => s.renameFolder);
|
||||
const deleteFolder = usePlaylistFolderStore(s => s.deleteFolder);
|
||||
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
|
||||
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
|
||||
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
||||
const targetId = folder ? folder.id : null;
|
||||
|
||||
// The whole section is the drop zone: a `psy-drop` released anywhere inside it
|
||||
// (header or a card in the grid — events bubble up) files the playlist here.
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: Event) => {
|
||||
setIsOver(false);
|
||||
try {
|
||||
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
|
||||
if (data.type === 'playlist' && data.id) setPlaylistFolder(serverId, data.id, targetId);
|
||||
} catch { /* ignore non-playlist drops */ }
|
||||
};
|
||||
el.addEventListener('psy-drop', handler);
|
||||
return () => el.removeEventListener('psy-drop', handler);
|
||||
}, [serverId, targetId, setPlaylistFolder]);
|
||||
|
||||
// Highlight while a drag hovers the section (mouse events still fire during the
|
||||
// custom drag, unlike native HTML5 DnD).
|
||||
const hoverProps = {
|
||||
onMouseMove: () => { if (isDragging && !isOver) setIsOver(true); },
|
||||
onMouseLeave: () => setIsOver(false),
|
||||
};
|
||||
|
||||
const grid = items.length > 0 && (
|
||||
<VirtualCardGrid
|
||||
items={items}
|
||||
itemKey={pl => pl.id}
|
||||
rowVariant="playlist"
|
||||
disableVirtualization={disableVirtualization}
|
||||
layoutSignal={items.length}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!folder) {
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={`playlist-folder playlist-folder--ungrouped${isOver ? ' drag-over' : ''}`}
|
||||
{...hoverProps}
|
||||
>
|
||||
<div className="playlist-folder-header playlist-folder-header--static">
|
||||
<Folder size={16} className="playlist-folder-icon" />
|
||||
<span className="playlist-folder-name playlist-folder-name--static">
|
||||
{t('playlists.folders.ungrouped')}
|
||||
</span>
|
||||
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
|
||||
</div>
|
||||
{items.length > 0 ? grid : (
|
||||
<div className="playlist-folder-dropzone">{t('playlists.folders.removeFromFolder')}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
if (draft.trim()) renameFolder(serverId, folder.id, draft.trim());
|
||||
setRenaming(false);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={`playlist-folder${isOver ? ' drag-over' : ''}`}
|
||||
{...hoverProps}
|
||||
>
|
||||
<div className="playlist-folder-header">
|
||||
<button
|
||||
className={`playlist-folder-toggle${folder.collapsed ? '' : ' expanded'}`}
|
||||
onClick={() => toggleFolderCollapsed(serverId, folder.id)}
|
||||
aria-expanded={!folder.collapsed}
|
||||
aria-label={folder.collapsed ? t('playlists.folders.expandFolder') : t('playlists.folders.collapseFolder')}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<Folder size={16} className="playlist-folder-icon" />
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="playlist-folder-rename-input"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') { setRenaming(false); setDraft(''); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button className="playlist-folder-name" onClick={() => toggleFolderCollapsed(serverId, folder.id)}>
|
||||
{folder.name}
|
||||
</button>
|
||||
)}
|
||||
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
|
||||
<div className="playlist-folder-actions">
|
||||
<button
|
||||
className="playlist-folder-action"
|
||||
data-tooltip={t('playlists.folders.rename')}
|
||||
aria-label={t('playlists.folders.rename')}
|
||||
onClick={() => { setRenaming(true); setDraft(folder.name); }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`playlist-folder-action playlist-folder-action--delete${confirmDelete ? ' is-confirm' : ''}`}
|
||||
data-tooltip={confirmDelete ? t('playlists.confirmDelete') : t('playlists.folders.delete')}
|
||||
aria-label={t('playlists.folders.delete')}
|
||||
onClick={() => {
|
||||
if (confirmDelete) { deleteFolder(serverId, folder.id); setConfirmDelete(false); }
|
||||
else setConfirmDelete(true);
|
||||
}}
|
||||
onMouseLeave={() => setConfirmDelete(false)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!folder.collapsed && (
|
||||
items.length === 0 ? (
|
||||
<div className="playlist-folder-empty">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
grid
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
import { groupPlaylistsByFolder } from '../../utils/playlist/playlistFolders';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import PlaylistFolderSection from './PlaylistFolderSection';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
playlists: SubsonicPlaylist[];
|
||||
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
|
||||
disableVirtualization: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlists page rendered as collapsible folder sections + an ungrouped
|
||||
* remainder. Each section reuses `VirtualCardGrid`, so the card layout and
|
||||
* virtualisation match the flat grid; only the grouping differs. Rendered only
|
||||
* when at least one folder exists (the page falls back to the plain grid).
|
||||
*/
|
||||
export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) {
|
||||
const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS;
|
||||
const { isDragging } = useDragDrop();
|
||||
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
|
||||
// Keep the ungrouped section as a drop target during a drag even when empty,
|
||||
// so a playlist filed into a folder can always be dragged back out to root.
|
||||
const showUngrouped = grouped.ungrouped.length > 0 || isDragging;
|
||||
|
||||
return (
|
||||
<div className="playlist-folder-view">
|
||||
{grouped.folders.map(({ folder, playlists: items }) => (
|
||||
<PlaylistFolderSection
|
||||
key={folder.id}
|
||||
serverId={serverId}
|
||||
folder={folder}
|
||||
items={items}
|
||||
renderCard={renderCard}
|
||||
disableVirtualization={disableVirtualization}
|
||||
/>
|
||||
))}
|
||||
{showUngrouped && (
|
||||
<PlaylistFolderSection
|
||||
serverId={serverId}
|
||||
folder={null}
|
||||
items={grouped.ungrouped}
|
||||
renderCard={renderCard}
|
||||
disableVirtualization={disableVirtualization}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
|
||||
/**
|
||||
* Header toggle to switch the Playlists page between the grouped folder view
|
||||
* and a single flat grid. Hidden until the active server has at least one
|
||||
* folder (nothing to switch otherwise).
|
||||
*/
|
||||
export default function PlaylistsFolderViewToggle() {
|
||||
const { t } = useTranslation();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const folderCount = usePlaylistFolderStore(
|
||||
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
|
||||
);
|
||||
const groupView = usePlaylistFolderStore(s => s.groupView);
|
||||
const toggleGroupView = usePlaylistFolderStore(s => s.toggleGroupView);
|
||||
|
||||
if (folderCount === 0) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-surface${groupView ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleGroupView}
|
||||
aria-pressed={groupView}
|
||||
data-tooltip={t('playlists.folders.groupByFolders')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={groupView ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<FolderTree size={15} /> {t('playlists.folders.groupByFolders')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
defaultSmartFilters, type SmartFilters,
|
||||
} from '../../utils/playlist/playlistsSmart';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
import PlaylistsNewFolderButton from './PlaylistsNewFolderButton';
|
||||
import PlaylistsFolderViewToggle from './PlaylistsFolderViewToggle';
|
||||
|
||||
interface Props {
|
||||
selectionMode: boolean;
|
||||
@@ -87,6 +89,8 @@ export default function PlaylistsHeader({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(selectionMode && selectedIds.size > 0) && <PlaylistsFolderViewToggle />}
|
||||
{!(selectionMode && selectedIds.size > 0) && <PlaylistsNewFolderButton />}
|
||||
{selectionMode && selectedIds.size > 0 && (() => {
|
||||
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderPlus } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
|
||||
/**
|
||||
* "New folder" action for the Playlists header row. Self-contained: toggles an
|
||||
* inline name input and creates a local folder for the active server. Folder
|
||||
* state is local-only, so this stays available offline.
|
||||
*/
|
||||
export default function PlaylistsNewFolderButton() {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const createFolder = usePlaylistFolderStore(s => s.createFolder);
|
||||
const folderCount = usePlaylistFolderStore(
|
||||
s => (serverId ? s.byServer[serverId]?.folders.length ?? 0 : 0),
|
||||
);
|
||||
const groupView = usePlaylistFolderStore(s => s.groupView);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) inputRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
if (!serverId) return null;
|
||||
// Only offer folder creation in the grouped view; keep it available before the
|
||||
// first folder exists (the toggle is hidden then, so this is the only entry).
|
||||
if (folderCount > 0 && !groupView) return null;
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) createFolder(serverId, trimmed);
|
||||
setName('');
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
if (creating) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ width: 180 }}
|
||||
placeholder={t('playlists.folders.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') submit();
|
||||
if (e.key === 'Escape') { setCreating(false); setName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={submit}>{t('playlists.folders.create')}</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="btn btn-surface" onClick={() => setCreating(true)}>
|
||||
<FolderPlus size={15} /> {t('playlists.folders.newFolder')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -44,23 +44,31 @@ export function QueueHeader({
|
||||
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
|
||||
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
|
||||
// the burst into a single low-priority commit so long queues do not block
|
||||
// the main thread on every cache tick. The aggregation itself is a single
|
||||
// pass — one loop produces both totals so a 50k-track queue costs one walk,
|
||||
// not two.
|
||||
// the main thread on every cache tick.
|
||||
//
|
||||
// The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`.
|
||||
// A skip moves only `queueIndex`, so it must not re-walk the whole queue: that
|
||||
// synchronous O(n) pass on every track change froze the UI for seconds on very
|
||||
// large queues (#1072, and the device-switch fallback in #1090). Instead we
|
||||
// build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks
|
||||
// [0, i)) once per queue/cache change, then derive the future total as an O(1)
|
||||
// lookup below. A 50k-track queue costs one walk per queue/cache change, zero
|
||||
// per track change.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
const deferredVersion = useDeferredValue(version);
|
||||
const { totalSecs, futureTracksDuration } = useMemo(() => {
|
||||
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
|
||||
let total = 0;
|
||||
let future = 0;
|
||||
const { totalSecs, cumSecs } = useMemo(() => {
|
||||
const cum = new Float64Array(queue.length + 1);
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const dur = resolveQueueTrack(queue[i]).duration || 0;
|
||||
total += dur;
|
||||
if (i > queueIndex) future += dur;
|
||||
cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0);
|
||||
}
|
||||
return { totalSecs: total, futureTracksDuration: future };
|
||||
return { totalSecs: cum[queue.length], cumSecs: cum };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, queueIndex, deferredVersion]);
|
||||
}, [queue, deferredVersion]);
|
||||
// Tracks strictly after the current index — O(1) per skip.
|
||||
const futureTracksDuration = Math.max(
|
||||
0,
|
||||
totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)],
|
||||
);
|
||||
|
||||
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
|
||||
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||
Blend, Check, FolderOpen, Infinity, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||
} from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
QueueToolbarButtonConfig,
|
||||
QueueToolbarButtonId,
|
||||
} from '../../store/queueToolbarStore';
|
||||
import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition';
|
||||
|
||||
interface Props {
|
||||
queue: QueueItemRef[];
|
||||
@@ -20,9 +21,8 @@ interface Props {
|
||||
handleCopyQueueShare: () => void;
|
||||
handleClear: () => void;
|
||||
gaplessEnabled: boolean;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
crossfadeEnabled: boolean;
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
crossfadeTrimSilence: boolean;
|
||||
crossfadeSecs: number;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
infiniteQueueEnabled: boolean;
|
||||
@@ -33,26 +33,38 @@ interface Props {
|
||||
export function QueueToolbar({
|
||||
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
|
||||
handleSave, handleLoad, handleCopyQueueShare, handleClear,
|
||||
gaplessEnabled, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
|
||||
crossfadeSecs, setCrossfadeSecs, infiniteQueueEnabled, setInfiniteQueueEnabled,
|
||||
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
|
||||
crossfadeSecs, setCrossfadeSecs,
|
||||
infiniteQueueEnabled, setInfiniteQueueEnabled,
|
||||
t,
|
||||
}: Props) {
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const playlistBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const playlistMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mode = getTransitionMode({ gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover) return;
|
||||
if (!showCrossfadePopover && !showPlaylistMenu) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
crossfadeBtnRef.current?.contains(e.target as Node) ||
|
||||
crossfadePopoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setShowCrossfadePopover(false);
|
||||
showCrossfadePopover &&
|
||||
!crossfadeBtnRef.current?.contains(target) &&
|
||||
!crossfadePopoverRef.current?.contains(target)
|
||||
) setShowCrossfadePopover(false);
|
||||
if (
|
||||
showPlaylistMenu &&
|
||||
!playlistBtnRef.current?.contains(target) &&
|
||||
!playlistMenuRef.current?.contains(target)
|
||||
) setShowPlaylistMenu(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
}, [showCrossfadePopover, showPlaylistMenu]);
|
||||
|
||||
return (
|
||||
<div className="queue-toolbar">
|
||||
@@ -66,24 +78,40 @@ export function QueueToolbar({
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'save':
|
||||
case 'playlist':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
|
||||
aria-label={t('queue.savePlaylist')}
|
||||
>
|
||||
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
|
||||
</button>
|
||||
);
|
||||
case 'load':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<div key={btn.id} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={playlistBtnRef}
|
||||
className={`queue-round-btn${showPlaylistMenu ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(v => !v); }}
|
||||
data-tooltip={showPlaylistMenu ? undefined : t('queue.playlist')}
|
||||
aria-label={t('queue.playlist')}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
</button>
|
||||
{showPlaylistMenu && (
|
||||
<div className="crossfade-popover queue-menu" ref={playlistMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="queue-menu-item"
|
||||
onClick={() => { handleSave(); setShowPlaylistMenu(false); }}
|
||||
disabled={saveState === 'saving'}
|
||||
>
|
||||
{saveState === 'saved' ? <Check size={14} /> : <Save size={14} />}
|
||||
{activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="queue-menu-item"
|
||||
onClick={() => { handleLoad(); setShowPlaylistMenu(false); }}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('queue.loadPlaylist')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'share':
|
||||
return (
|
||||
@@ -109,8 +137,8 @@ export function QueueToolbar({
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
|
||||
className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setTransitionMode(mode === 'gapless' ? 'none' : 'gapless'); }}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
@@ -122,16 +150,17 @@ export function QueueToolbar({
|
||||
<div key={btn.id} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
className={`queue-round-btn${mode === 'crossfade' || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
// Left click toggles classic crossfade on/off. Right click
|
||||
// opens the seconds popover.
|
||||
setShowPlaylistMenu(false);
|
||||
setTransitionMode(mode === 'crossfade' ? 'none' : 'crossfade');
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setShowPlaylistMenu(false);
|
||||
setShowCrossfadePopover(v => !v);
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
@@ -153,9 +182,10 @@ export function QueueToolbar({
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
setTransitionMode('crossfade');
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
aria-label={t('queue.crossfade')}
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
@@ -164,6 +194,18 @@ export function QueueToolbar({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'autodj':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(false); setTransitionMode(mode === 'autodj' ? 'none' : 'autodj'); }}
|
||||
data-tooltip={t('queue.autoDj')}
|
||||
aria-label={t('queue.autoDj')}
|
||||
>
|
||||
<Blend size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'infinite':
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -253,11 +254,12 @@ export default function AnalyticsStrategySection() {
|
||||
icon={<BarChart3 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', paddingLeft: 0, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.analyticsStrategyServerLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
@@ -284,7 +286,7 @@ export default function AnalyticsStrategySection() {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
@@ -365,7 +367,7 @@ export default function AnalyticsStrategySection() {
|
||||
key={serverId}
|
||||
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}
|
||||
>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div>{serverId}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
|
||||
{t('settings.analyticsStrategyServerRemoved')}
|
||||
@@ -432,6 +434,7 @@ export default function AnalyticsStrategySection() {
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
{clearTarget &&
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useFontStore, FontId } from '../../store/fontStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||
import WindowButtonPreview from '../WindowButtonPreview';
|
||||
|
||||
@@ -34,38 +36,40 @@ export function AppearanceTab() {
|
||||
icon={<LayoutGrid size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 360 }}
|
||||
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
aria-valuenow={auth.libraryGridMaxColumns}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
<SettingsGroup>
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 360 }}
|
||||
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
aria-valuenow={auth.libraryGridMaxColumns}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -74,103 +78,69 @@ export function AppearanceTab() {
|
||||
icon={<Palette size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showOrbitTrigger')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showOrbitTriggerDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showOrbitTrigger')}>
|
||||
<input type="checkbox" checked={auth.showOrbitTrigger} onChange={e => auth.setShowOrbitTrigger(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{!IS_WINDOWS && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadMiniPlayerDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.preloadMiniPlayer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.preloadMiniPlayer}
|
||||
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SettingsGroup title={t('settings.groupDisplay')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.coverArtBackground')}
|
||||
desc={t('settings.coverArtBackgroundSub')}
|
||||
checked={theme.enableCoverArtBackground}
|
||||
onChange={theme.setEnableCoverArtBackground}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.playlistCoverPhoto')}
|
||||
desc={t('settings.playlistCoverPhotoSub')}
|
||||
checked={theme.enablePlaylistCoverPhoto}
|
||||
onChange={theme.setEnablePlaylistCoverPhoto}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showBitrate')}
|
||||
desc={t('settings.showBitrateSub')}
|
||||
checked={theme.showBitrate}
|
||||
onChange={theme.setShowBitrate}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.floatingPlayerBar')}
|
||||
desc={t('settings.floatingPlayerBarSub')}
|
||||
checked={theme.floatingPlayerBar}
|
||||
onChange={theme.setFloatingPlayerBar}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showArtistImages')}
|
||||
desc={t('settings.showArtistImagesDesc')}
|
||||
checked={auth.showArtistImages}
|
||||
onChange={auth.setShowArtistImages}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showOrbitTrigger')}
|
||||
desc={t('settings.showOrbitTriggerDesc')}
|
||||
checked={auth.showOrbitTrigger}
|
||||
onChange={auth.setShowOrbitTrigger}
|
||||
/>
|
||||
{!IS_WINDOWS && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.preloadMiniPlayer')}
|
||||
desc={t('settings.preloadMiniPlayerDesc')}
|
||||
checked={auth.preloadMiniPlayer}
|
||||
onChange={auth.setPreloadMiniPlayer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{IS_LINUX && !isTilingWm && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.useCustomTitlebar')}>
|
||||
<input type="checkbox" checked={auth.useCustomTitlebar} onChange={e => auth.setUseCustomTitlebar(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.groupWindow')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.useCustomTitlebar')}
|
||||
desc={t('settings.useCustomTitlebarDesc')}
|
||||
checked={auth.useCustomTitlebar}
|
||||
onChange={auth.setUseCustomTitlebar}
|
||||
/>
|
||||
{auth.useCustomTitlebar && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
@@ -192,19 +162,15 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showMinimizeButton')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showMinimizeButtonDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showMinimizeButton')}>
|
||||
<input type="checkbox" checked={auth.showMinimizeButton} onChange={e => auth.setShowMinimizeButton(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.showMinimizeButton')}
|
||||
desc={t('settings.showMinimizeButtonDesc')}
|
||||
checked={auth.showMinimizeButton}
|
||||
onChange={auth.setShowMinimizeButton}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -214,57 +180,59 @@ export function AppearanceTab() {
|
||||
icon={<ZoomIn size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -273,47 +241,49 @@ export function AppearanceTab() {
|
||||
icon={<Type size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
// Accessibility-first: OpenDyslexic at the top so dyslexic
|
||||
// readers don't have to scroll past 14 sans-serifs to find it.
|
||||
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
|
||||
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string; hint?: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
fontFamily: f.stack,
|
||||
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
|
||||
}}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
{f.hint && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
|
||||
{f.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
// Accessibility-first: OpenDyslexic at the top so dyslexic
|
||||
// readers don't have to scroll past 14 sans-serifs to find it.
|
||||
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
|
||||
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string; hint?: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
fontFamily: f.stack,
|
||||
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
|
||||
}}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
{f.hint && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
|
||||
{f.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -322,20 +292,22 @@ export function AppearanceTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||||
<SeekbarPreview
|
||||
key={style}
|
||||
style={style}
|
||||
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||||
selected={auth.seekbarStyle === style}
|
||||
onClick={() => auth.setSeekbarStyle(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||||
<SeekbarPreview
|
||||
key={style}
|
||||
style={style}
|
||||
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||||
selected={auth.seekbarStyle === style}
|
||||
onClick={() => auth.setSeekbarStyle(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -94,7 +94,6 @@ export function ArtistLayoutCustomizer() {
|
||||
{t('settings.artistLayoutDesc')}
|
||||
</p>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0' }}
|
||||
ref={setContainerEl}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music2, Sliders, Waves } from 'lucide-react';
|
||||
import { Blend, Gauge, Sliders, Volume2, Waves } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import Equalizer from '../Equalizer';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
|
||||
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
|
||||
import { IS_MACOS } from '../../utils/platform';
|
||||
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
|
||||
import { NormalizationBlock } from './audio/NormalizationBlock';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import { PlaybackBehaviorBlock } from './audio/PlaybackBehaviorBlock';
|
||||
import { PlaybackRateBlock } from './audio/PlaybackRateBlock';
|
||||
import { TrackTransitionsBlock } from './audio/TrackTransitionsBlock';
|
||||
import { TrackPreviewsSection } from './audio/TrackPreviewsSection';
|
||||
|
||||
export function AudioTab() {
|
||||
@@ -35,15 +37,41 @@ export function AudioTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AudioOutputDeviceSection
|
||||
audioDevices={audioDevices}
|
||||
osDefaultAudioDeviceId={osDefaultAudioDeviceId}
|
||||
deviceSwitching={deviceSwitching}
|
||||
devicesLoading={devicesLoading}
|
||||
setDeviceSwitching={setDeviceSwitching}
|
||||
refreshAudioDevices={refreshAudioDevices}
|
||||
t={t}
|
||||
/>
|
||||
{/* Output-device picker is hidden on macOS — the stream is pinned to the
|
||||
system default there, so the whole category is gated out. */}
|
||||
{!IS_MACOS && (
|
||||
<AudioOutputDeviceSection
|
||||
audioDevices={audioDevices}
|
||||
osDefaultAudioDeviceId={osDefaultAudioDeviceId}
|
||||
deviceSwitching={deviceSwitching}
|
||||
devicesLoading={devicesLoading}
|
||||
setDeviceSwitching={setDeviceSwitching}
|
||||
refreshAudioDevices={refreshAudioDevices}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Normalization — loudness levelling (own category) */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.normalization')}
|
||||
description={t('settings.normalizationDesc')}
|
||||
icon={<Volume2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<NormalizationBlock preAnalysisEffectiveDb={preAnalysisEffectiveDb} t={t} />
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Track transitions — crossfade / gapless / AutoDJ (own category) */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.transitionsTitle')}
|
||||
description={t('settings.transitionsDesc')}
|
||||
icon={<Blend size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<TrackTransitionsBlock t={t} />
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Native Hi-Res Playback */}
|
||||
<SettingsSubSection
|
||||
@@ -51,21 +79,15 @@ export function AudioTab() {
|
||||
icon={<Waves size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||
id="hires-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.hiResDesc')}
|
||||
ariaLabel={t('settings.hiResEnabled')}
|
||||
id="hires-enabled-toggle"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={auth.setEnableHiRes}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -75,7 +97,9 @@ export function AudioTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<Equalizer />
|
||||
<SettingsGroup>
|
||||
<Equalizer />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -85,21 +109,9 @@ export function AudioTab() {
|
||||
icon={<Gauge size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<PlaybackRateBlock t={t} />
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Replay Gain + Crossfade + Gapless */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.playbackTitle')}
|
||||
icon={<Music2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<NormalizationBlock preAnalysisEffectiveDb={preAnalysisEffectiveDb} t={t} />
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<PlaybackBehaviorBlock t={t} />
|
||||
<SettingsGroup>
|
||||
<PlaybackRateBlock t={t} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock3, Download, HardDrive, Upload } from 'lucide-react';
|
||||
import { Clock3, Download, Upload } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import {
|
||||
exportBackupToPath,
|
||||
importAnyBackupFromPath,
|
||||
@@ -148,13 +149,8 @@ export function BackupSection() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2>{t('settings.backupTitle')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
|
||||
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
|
||||
<button
|
||||
@@ -197,8 +193,8 @@ export function BackupSection() {
|
||||
{importing ? '…' : importLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
{busyOverlay}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'r
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -232,12 +233,13 @@ export default function CoverCacheStrategySection() {
|
||||
return (
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
|
||||
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.imageCacheScopeLabel')}</th>
|
||||
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
@@ -245,7 +247,7 @@ export default function CoverCacheStrategySection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t('settings.imageCacheSubTitle')}
|
||||
</td>
|
||||
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||
@@ -285,7 +287,7 @@ export default function CoverCacheStrategySection() {
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyServerLabel')}</th>
|
||||
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.coverCacheStrategyServerLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
@@ -299,7 +301,7 @@ export default function CoverCacheStrategySection() {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{COVER_CACHE_STRATEGIES.map(s => (
|
||||
@@ -338,7 +340,7 @@ export default function CoverCacheStrategySection() {
|
||||
})}
|
||||
{removedServerKeys.map(key => (
|
||||
<tr key={`removed-${key}`} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{key}
|
||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||
</td>
|
||||
@@ -414,6 +416,7 @@ export default function CoverCacheStrategySection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HomeCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="sidebar-customizer-row">
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../../config/s
|
||||
import { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../../store/globalShortcutsStore';
|
||||
import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
|
||||
export function InputTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,69 +33,71 @@ export function InputTab() {
|
||||
}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const chord = buildInAppBinding(e);
|
||||
if (!chord) return;
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === chord)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, chord);
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const chord = buildInAppBinding(e);
|
||||
if (!chord) return;
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === chord)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, chord);
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
)}
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -116,67 +119,69 @@ export function InputTab() {
|
||||
}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
)}
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Card {
|
||||
fixed: boolean;
|
||||
accessibility: boolean;
|
||||
animated: boolean;
|
||||
/** Community themes carry a manifest version; fixed cores do not. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +66,12 @@ export function InstalledThemes() {
|
||||
...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility, animated: false })),
|
||||
...installed.map(it => {
|
||||
const s = swatch(it.css);
|
||||
return { id: it.id, label: it.name, bg: s.bg, card: s.card, accent: s.accent, fixed: false, accessibility: (it.tags || []).includes('accessibility'), animated: /@(?:-[a-z]+-)?keyframes\b/i.test(it.css) };
|
||||
return { id: it.id, label: it.name, bg: s.bg, card: s.card, accent: s.accent, fixed: false, accessibility: (it.tags || []).includes('accessibility'), animated: /@(?:-[a-z]+-)?keyframes\b/i.test(it.css), version: it.version };
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
|
||||
{cards.map(c => {
|
||||
const isActive = active === c.id;
|
||||
@@ -102,6 +104,11 @@ export function InstalledThemes() {
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
{c.version && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', display: 'block', textAlign: 'center', lineHeight: 1.2 }}>
|
||||
v{c.version}
|
||||
</span>
|
||||
)}
|
||||
{c.accessibility && (
|
||||
<span
|
||||
aria-label={t('settings.themesCvdTooltip')}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||
|
||||
export function IntegrationsTab() {
|
||||
@@ -41,55 +43,37 @@ export function IntegrationsTab() {
|
||||
>
|
||||
{t('settings.discordRichPresenceNotice')}
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.discordRichPresence')}>
|
||||
<SettingsToggle
|
||||
desc={t('settings.discordRichPresenceDesc')}
|
||||
ariaLabel={t('settings.discordRichPresence')}
|
||||
checked={auth.discordRichPresence}
|
||||
onChange={auth.setDiscordRichPresence}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
{auth.discordRichPresence && (
|
||||
<>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverNone')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverNone')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'none'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'none' : 'server')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverServer')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverServer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'server'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'server' : 'none')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverApple')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverApple')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'apple'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'apple' : 'none')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div>
|
||||
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverNone')}
|
||||
checked={auth.discordCoverSource === 'none'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverServer')}
|
||||
checked={auth.discordCoverSource === 'server'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverApple')}
|
||||
checked={auth.discordCoverSource === 'apple'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
|
||||
<input
|
||||
@@ -130,7 +114,7 @@ export function IntegrationsTab() {
|
||||
placeholder="{album}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,16 +126,14 @@ export function IntegrationsTab() {
|
||||
icon={<Info size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.enableBandsintown')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableBandsintownDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.enableBandsintown')}>
|
||||
<input type="checkbox" checked={auth.enableBandsintown} onChange={e => auth.setEnableBandsintown(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.enableBandsintownDesc')}
|
||||
ariaLabel={t('settings.enableBandsintown')}
|
||||
checked={auth.enableBandsintown}
|
||||
onChange={auth.setEnableBandsintown}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -161,17 +143,15 @@ export function IntegrationsTab() {
|
||||
icon={<Wifi size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{t('settings.nowPlayingPluginNote')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.nowPlayingEnabledDesc')}
|
||||
note={t('settings.nowPlayingPluginNote')}
|
||||
ariaLabel={t('settings.nowPlayingEnabled')}
|
||||
checked={auth.nowPlayingEnabled}
|
||||
onChange={auth.setNowPlayingEnabled}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Shuffle, Star } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import StarRating from '../StarRating';
|
||||
import AnalyticsStrategySection from './AnalyticsStrategySection';
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
@@ -23,99 +25,80 @@ export function LibraryTab() {
|
||||
icon={<Shuffle size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
<SettingsGroup>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.luckyMixMenuTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.luckyMixMenuDesc')}
|
||||
</div>
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.luckyMixMenuTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.showLuckyMixMenu}
|
||||
onChange={e => auth.setShowLuckyMixMenu(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -125,101 +108,93 @@ export function LibraryTab() {
|
||||
icon={<Star size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{auth.skipStarOnManualSkipsEnabled && (
|
||||
<>
|
||||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="settings-skip-star-threshold"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={auth.skipStarManualSkipThreshold}
|
||||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.skipStarOnManualSkipsEnabled}
|
||||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.ratingsMixFilterDesc', {
|
||||
mix: t('sidebar.randomMix'),
|
||||
albums: t('sidebar.randomAlbums'),
|
||||
})}
|
||||
<SettingsGroup>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.mixMinRatingFilterEnabled}
|
||||
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))',
|
||||
gap: '1rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{([
|
||||
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||
] as const).map(row => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||
<StarRating
|
||||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||
value={row.value}
|
||||
onChange={row.set}
|
||||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{auth.skipStarOnManualSkipsEnabled && (
|
||||
<>
|
||||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="settings-skip-star-threshold"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={auth.skipStarManualSkipThreshold}
|
||||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.skipStarOnManualSkipsEnabled}
|
||||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<SettingsToggle
|
||||
label={t('settings.ratingsMixFilterTitle')}
|
||||
desc={t('settings.ratingsMixFilterDesc', {
|
||||
mix: t('sidebar.randomMix'),
|
||||
albums: t('sidebar.randomAlbums'),
|
||||
})}
|
||||
checked={auth.mixMinRatingFilterEnabled}
|
||||
onChange={auth.setMixMinRatingFilterEnabled}
|
||||
/>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))',
|
||||
gap: '1rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{([
|
||||
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||
] as const).map(row => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||
<StarRating
|
||||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||
value={row.value}
|
||||
onChange={row.set}
|
||||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, Music2 } from 'lucide-react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { LyricsSourceId } from '../../store/authStoreTypes';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
|
||||
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
|
||||
server: 'settings.lyricsSourceServer',
|
||||
@@ -98,11 +99,7 @@ export function LyricsSourcesCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lyricsSourcesTitle')}</h2>
|
||||
</div>
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||
{t('settings.lyricsSourcesDesc')}
|
||||
</p>
|
||||
@@ -110,28 +107,19 @@ export function LyricsSourcesCustomizer() {
|
||||
{/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and
|
||||
the enabled sources below act as fallback; when off only those sources
|
||||
are used. YouLyPlus off + every source off = lyrics fully disabled. */}
|
||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsYouLyPlus')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsYouLyPlusDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.lyricsYouLyPlus')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={youLyPlusEnabled}
|
||||
onChange={e => setYouLyPlusEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.lyricsYouLyPlus')}
|
||||
desc={t('settings.lyricsYouLyPlusDesc')}
|
||||
checked={youLyPlusEnabled}
|
||||
onChange={setYouLyPlusEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
|
||||
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
|
||||
</div>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0', marginBottom: '0.75rem' }}
|
||||
ref={setContainerEl}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -162,18 +150,14 @@ export function LyricsSourcesCustomizer() {
|
||||
</div>
|
||||
|
||||
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
|
||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
|
||||
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.lyricsStaticOnly')}
|
||||
desc={t('settings.lyricsStaticOnlyDesc')}
|
||||
checked={lyricsStaticOnly}
|
||||
onChange={setLyricsStaticOnly}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, Music2 } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer';
|
||||
|
||||
export function LyricsTab() {
|
||||
@@ -15,37 +17,32 @@ export function LyricsTab() {
|
||||
title={t('settings.lyricsSourcesTitle')}
|
||||
icon={<Music2 size={16} />}
|
||||
>
|
||||
<LyricsSourcesCustomizer />
|
||||
<SettingsGroup>
|
||||
<LyricsSourcesCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.sidebarLyricsStyle')}
|
||||
icon={<AudioLines size={16} />}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{(['classic', 'apple'] as const).map(style => {
|
||||
<SettingsGroup>
|
||||
{(['classic', 'apple'] as const).map((style, i) => {
|
||||
const key = style === 'classic' ? 'Classic' : 'Apple';
|
||||
const other = style === 'classic' ? 'apple' : 'classic';
|
||||
return (
|
||||
<div key={style} className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.sidebarLyricsStyle${key}` as any)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sidebarLyricsStyle === style}
|
||||
onChange={e => setSidebarLyricsStyle(e.target.checked ? style : other)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div key={style}>
|
||||
{i > 0 && <div className="settings-section-divider" />}
|
||||
<SettingsToggle
|
||||
label={t(`settings.sidebarLyricsStyle${key}` as any)}
|
||||
desc={t(`settings.sidebarLyricsStyle${key}Desc` as any)}
|
||||
checked={sidebarLyricsStyle === style}
|
||||
onChange={c => setSidebarLyricsStyle(c ? style : other)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, LayoutGrid, ListMusic, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
|
||||
import { Disc3, LayoutGrid, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
|
||||
import { useArtistLayoutStore } from '../../store/artistLayoutStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useHomeStore } from '../../store/homeStore';
|
||||
@@ -8,6 +8,8 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||
import { useQueueToolbarStore } from '../../store/queueToolbarStore';
|
||||
import { useSidebarStore } from '../../store/sidebarStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
|
||||
import { HomeCustomizer } from './HomeCustomizer';
|
||||
import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer';
|
||||
@@ -19,6 +21,9 @@ export function PersonalisationTab() {
|
||||
const { t } = useTranslation();
|
||||
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
|
||||
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
|
||||
const preservePlayNextOrder = useAuthStore(s => s.preservePlayNextOrder);
|
||||
const setPreservePlayNextOrder = useAuthStore(s => s.setPreservePlayNextOrder);
|
||||
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
|
||||
return (
|
||||
<>
|
||||
<SettingsSubSection
|
||||
@@ -56,7 +61,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<HomeCustomizer />
|
||||
<SettingsGroup>
|
||||
<HomeCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -76,82 +83,74 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ArtistLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<ArtistLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Queue Settings — display mode, queue behaviour and (advanced) the
|
||||
toolbar customizer, grouped under one category. */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.queueModeTitle')}
|
||||
title={t('settings.queueSettingsTitle')}
|
||||
icon={<ListOrdered size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.title')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeQueueSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.title')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'queue'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('queue'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modePlaylist')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModePlaylistSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modePlaylist')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'playlist'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('playlist'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
<>
|
||||
<SettingsGroup title={t('settings.queueModeTitle')}>
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<SettingsToggle
|
||||
label={t('queue.title')}
|
||||
desc={t('settings.queueModeQueueSub')}
|
||||
checked={queueDisplayMode === 'queue'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('queue'); }}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('queue.modePlaylist')}
|
||||
desc={t('settings.queueModePlaylistSub')}
|
||||
checked={queueDisplayMode === 'playlist'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('playlist'); }}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('queue.modeTimeline')}
|
||||
desc={t('settings.queueModeTimelineSub')}
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.queueToolbarTitle')}
|
||||
icon={<ListMusic size={16} />}
|
||||
advanced
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
|
||||
onClick={() => useQueueToolbarStore.getState().reset()}
|
||||
data-tooltip={t('settings.queueToolbarReset')}
|
||||
aria-label={t('settings.queueToolbarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<QueueToolbarCustomizer />
|
||||
<SettingsGroup title={t('settings.queueBehaviourTitle')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.preservePlayNextOrder')}
|
||||
desc={t('settings.preservePlayNextOrderDesc')}
|
||||
checked={preservePlayNextOrder}
|
||||
onChange={setPreservePlayNextOrder}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{advancedSettingsEnabled && (
|
||||
<SettingsGroup
|
||||
title={t('settings.queueToolbarTitle')}
|
||||
advanced
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
|
||||
onClick={() => useQueueToolbarStore.getState().reset()}
|
||||
data-tooltip={t('settings.queueToolbarReset')}
|
||||
aria-label={t('settings.queueToolbarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<QueueToolbarCustomizer />
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -171,7 +170,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PlaylistLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<PlaylistLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -191,7 +192,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PlayerBarLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<PlayerBarLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function PlayerBarLayoutCustomizer() {
|
||||
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function PlaylistLayoutCustomizer() {
|
||||
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
|
||||
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderOpen, GripVertical, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
|
||||
import { Blend, GripVertical, Infinity, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore';
|
||||
|
||||
@@ -8,25 +8,25 @@ type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
|
||||
|
||||
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
|
||||
shuffle: Shuffle,
|
||||
save: Save,
|
||||
load: FolderOpen,
|
||||
playlist: ListMusic,
|
||||
share: Share2,
|
||||
clear: Trash2,
|
||||
separator: null, // No icon for separator
|
||||
gapless: MoveRight,
|
||||
crossfade: Waves,
|
||||
autodj: Blend,
|
||||
infinite: Infinity,
|
||||
};
|
||||
|
||||
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
|
||||
shuffle: 'queue.shuffle',
|
||||
save: 'queue.savePlaylist',
|
||||
load: 'queue.loadPlaylist',
|
||||
playlist: 'queue.playlist',
|
||||
share: 'queue.shareQueue',
|
||||
clear: 'queue.clear',
|
||||
separator: 'settings.queueToolbarSeparator',
|
||||
gapless: 'queue.gapless',
|
||||
crossfade: 'queue.crossfade',
|
||||
autodj: 'queue.autoDj',
|
||||
infinite: 'queue.infiniteQueue',
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ export function QueueToolbarCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ padding: '4px 0' }}>
|
||||
{buttons.map((btn, idx) => {
|
||||
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
|
||||
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
|
||||
@@ -124,7 +124,11 @@ export function QueueToolbarCustomizer() {
|
||||
{Icon ? (
|
||||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)', flexShrink: 0 }} />
|
||||
// Reserve the same 16px icon column so the label lines up with the
|
||||
// other rows; the 1px rule is centred within it.
|
||||
<div style={{ width: 16, display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)' }} />
|
||||
</div>
|
||||
)}
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
|
||||
@@ -63,19 +63,19 @@ export default function ServerLibraryIndexControls({
|
||||
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45, marginBottom: '0.5rem' }}>
|
||||
{connection === 'offline' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '0.5rem' }}>
|
||||
<WifiOff size={12} />
|
||||
{t('settings.libraryIndexServerDeferred')}
|
||||
<div style={{ fontSize: 12, lineHeight: 1.45, marginBottom: '0.5rem' }}>
|
||||
{connection === 'offline' ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, color: 'var(--text-muted)' }}>
|
||||
<WifiOff size={12} style={{ flexShrink: 0 }} />
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{busy && (
|
||||
<span style={{ color: 'var(--accent)', marginRight: '0.5rem' }}>
|
||||
{t('settings.libraryIndexServerSyncing')}
|
||||
) : busy ? (
|
||||
<span style={{ color: 'var(--accent)' }}>
|
||||
{t('settings.libraryIndexServerSyncing')} {phaseLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>{phaseLabel}</span>
|
||||
)}
|
||||
{phaseLabel}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
/** Accent uppercase header. Omit for a plain boxed panel (no header) —
|
||||
* used when the surrounding SettingsSubSection already names the group. */
|
||||
title?: string;
|
||||
/** Optional accent-coloured icon shown before the title (e.g. on the flat
|
||||
* Themes sections). Ignored when `title` is omitted. */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional one-line description shown under the title. */
|
||||
desc?: string;
|
||||
/** Show an "Advanced" badge after the title (e.g. for a group that only
|
||||
* renders in Advanced Mode). Ignored when `title` is omitted. */
|
||||
advanced?: boolean;
|
||||
/** Optional right-aligned node in the title row (e.g. a reset button).
|
||||
* Ignored when `title` is omitted. */
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxed settings sub-section — a bordered panel (optionally with an accent
|
||||
* uppercase header) that sets a group of related controls apart inside a
|
||||
* settings card. Wraps the `.settings-group` styles so the look stays
|
||||
* consistent everywhere it is used (Audio, Appearance, Library, …).
|
||||
*/
|
||||
export function SettingsGroup({ title, icon, desc, advanced, action, children }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="settings-group">
|
||||
{title && (
|
||||
<div className="settings-group-title">
|
||||
{icon && <span className="settings-group-title-icon">{icon}</span>}
|
||||
{title}
|
||||
{(advanced || action) && (
|
||||
<span className="settings-group-title-end">
|
||||
{advanced && (
|
||||
<span className="settings-sub-section-advanced-badge">
|
||||
{t('settings.advancedBadge')}
|
||||
</span>
|
||||
)}
|
||||
{action}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-group-body">
|
||||
{desc && <div className="settings-group-desc">{desc}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Bold label. Omit for a desc-only row whose title is the enclosing group
|
||||
* header — pass `ariaLabel` then so the switch keeps an accessible name. */
|
||||
label?: string;
|
||||
/** Muted description under the label (string, or JSX for inline links). */
|
||||
desc?: ReactNode;
|
||||
/** Bold secondary note under the description (e.g. a requirement hint). */
|
||||
note?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Dims the row and blocks interaction (e.g. mutually-exclusive options). */
|
||||
disabled?: boolean;
|
||||
/** Overrides the toggle's accessible name when it should differ from the label. */
|
||||
ariaLabel?: string;
|
||||
/** Indexes the row for the settings search (data-settings-search). */
|
||||
searchText?: string;
|
||||
/** Forwarded to the checkbox input (e.g. a tour/onboarding anchor). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard settings toggle row — a label/description on the left and a switch
|
||||
* on the right. Centralises the markup repeated across every settings tab so
|
||||
* sections only describe what they toggle, not how a toggle row looks.
|
||||
*/
|
||||
export function SettingsToggle({ label, desc, note, checked, onChange, disabled, ariaLabel, searchText, id }: Props) {
|
||||
return (
|
||||
<div className="settings-toggle-row" data-settings-search={searchText} style={disabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
{label && <div style={{ fontWeight: 500 }}>{label}</div>}
|
||||
{desc && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{desc}</div>}
|
||||
{note && <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{note}</div>}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={ariaLabel ?? label}>
|
||||
<input type="checkbox" id={id} checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '.
|
||||
import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
|
||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||
|
||||
@@ -41,6 +43,8 @@ export function SidebarCustomizer() {
|
||||
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
||||
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
|
||||
const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const setShowLuckyMixMenu = useAuthStore(s => s.setShowLuckyMixMenu);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -125,51 +129,45 @@ export function SidebarCustomizer() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.randomNavSplitTitle')}
|
||||
desc={t('settings.randomNavSplitDesc')}
|
||||
checked={randomNavMode === 'separate'}
|
||||
onChange={c => setRandomNavMode(c ? 'separate' : 'hub')}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t('settings.nowPlayingTopTitle')}
|
||||
desc={t('settings.nowPlayingTopDesc')}
|
||||
searchText={t('settings.nowPlayingTopTitle')}
|
||||
checked={nowPlayingAtTop}
|
||||
onChange={setNowPlayingAtTop}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t('settings.luckyMixMenuTitle')}
|
||||
desc={t('settings.luckyMixMenuDesc')}
|
||||
checked={showLuckyMixMenu}
|
||||
onChange={setShowLuckyMixMenu}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={randomNavMode === 'separate'}
|
||||
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" data-settings-search={t('settings.nowPlayingTopTitle')}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingTopTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingTopDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingTopTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nowPlayingAtTop}
|
||||
onChange={e => setNowPlayingAtTop(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||
</div>
|
||||
{/* System block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||
{/* System block */}
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { countHotCacheTracks } from '../../store/hotCacheStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
|
||||
export function StorageTab() {
|
||||
@@ -74,36 +76,35 @@ export function StorageTab() {
|
||||
icon={<FolderOpen size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.mediaDirDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.mediaDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||
data-tooltip={t('settings.mediaDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
<SettingsGroup desc={t('settings.mediaDirDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.mediaDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||
data-tooltip={t('settings.mediaDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -114,78 +115,72 @@ export function StorageTab() {
|
||||
icon={<Download size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async e => {
|
||||
const enabled = e.target.checked;
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
refreshHotCacheSize();
|
||||
}
|
||||
}}
|
||||
id="hot-cache-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.hotCacheTitle')}
|
||||
desc={t('settings.hotCacheDisclaimer')}
|
||||
ariaLabel={t('settings.hotCacheEnabled')}
|
||||
id="hot-cache-enabled-toggle"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async enabled => {
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
refreshHotCacheSize();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
refreshHotCacheSize();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -194,32 +189,31 @@ export function StorageTab() {
|
||||
icon={<FolderOpen size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.downloadsFolderDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
<SettingsGroup desc={t('settings.downloadsFolderDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import LicensesPanel from '../LicensesPanel';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { BackupSection } from './BackupSection';
|
||||
import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits';
|
||||
|
||||
@@ -55,23 +57,25 @@ export function SystemTab() {
|
||||
icon={<Globe size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'es', label: t('settings.languageEs') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
{ value: 'ru', label: t('settings.languageRu') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
{ value: 'ro', label: t('settings.languageRo') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'es', label: t('settings.languageEs') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
{ value: 'ru', label: t('settings.languageRu') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
{ value: 'ro', label: t('settings.languageRo') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -81,59 +85,44 @@ export function SystemTab() {
|
||||
icon={<AppWindow size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
|
||||
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.groupTray')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.showTrayIcon')}
|
||||
desc={t('settings.showTrayIconDesc')}
|
||||
checked={auth.showTrayIcon}
|
||||
onChange={auth.setShowTrayIcon}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.minimizeToTray')}
|
||||
desc={t('settings.minimizeToTrayDesc')}
|
||||
checked={auth.minimizeToTray}
|
||||
onChange={auth.setMinimizeToTray}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.touchpadBackForwardGestures')}
|
||||
desc={t('settings.touchpadBackForwardGesturesDesc')}
|
||||
checked={auth.touchpadBackForwardGestures}
|
||||
onChange={auth.setTouchpadBackForwardGestures}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{IS_LINUX && (
|
||||
<>
|
||||
<SettingsGroup title={t('settings.groupLinuxRendering')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.linuxWebkitSmoothScroll')}
|
||||
desc={t('settings.linuxWebkitSmoothScrollDesc')}
|
||||
checked={auth.linuxWebkitKineticScroll}
|
||||
onChange={auth.setLinuxWebkitKineticScroll}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.linuxWebkitKineticScroll}
|
||||
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitInputForceRepaint')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitInputForceRepaintDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.linuxWebkitInputForceRepaint')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.linuxWebkitInputForceRepaint}
|
||||
onChange={e => auth.setLinuxWebkitInputForceRepaint(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.linuxWebkitInputForceRepaint')}
|
||||
desc={t('settings.linuxWebkitInputForceRepaintDesc')}
|
||||
checked={auth.linuxWebkitInputForceRepaint}
|
||||
onChange={auth.setLinuxWebkitInputForceRepaint}
|
||||
/>
|
||||
{waylandTextRenderAvailable && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
@@ -155,26 +144,28 @@ export function SystemTab() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||
|
||||
<SettingsGroup title={t('settings.groupClock')}>
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<CustomSelect
|
||||
value={auth.clockFormat}
|
||||
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.clockFormatAuto') },
|
||||
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
|
||||
{ value: '12h', label: t('settings.clockFormatTwelve') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<CustomSelect
|
||||
value={auth.clockFormat}
|
||||
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.clockFormatAuto') },
|
||||
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
|
||||
{ value: '12h', label: t('settings.clockFormatTwelve') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -190,26 +181,28 @@ export function SystemTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.loggingModeDesc')}
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={auth.loggingMode}
|
||||
onChange={(v) => auth.setLoggingMode(v as LoggingMode)}
|
||||
options={[
|
||||
{ value: 'off', label: t('settings.loggingModeOff') },
|
||||
{ value: 'normal', label: t('settings.loggingModeNormal') },
|
||||
{ value: 'debug', label: t('settings.loggingModeDebug') },
|
||||
]}
|
||||
/>
|
||||
{auth.loggingMode === 'debug' && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||
<Download size={14} />
|
||||
{t('settings.loggingExport')}
|
||||
</button>
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.loggingModeDesc')}
|
||||
</div>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={auth.loggingMode}
|
||||
onChange={(v) => auth.setLoggingMode(v as LoggingMode)}
|
||||
options={[
|
||||
{ value: 'off', label: t('settings.loggingModeOff') },
|
||||
{ value: 'normal', label: t('settings.loggingModeNormal') },
|
||||
{ value: 'debug', label: t('settings.loggingModeDebug') },
|
||||
]}
|
||||
/>
|
||||
{auth.loggingMode === 'debug' && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||
<Download size={14} />
|
||||
{t('settings.loggingExport')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -271,20 +264,12 @@ export function SystemTab() {
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" style={{ marginTop: '1.25rem' }} />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.showChangelogOnUpdate}
|
||||
onChange={e => auth.setShowChangelogOnUpdate(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.showChangelogOnUpdate')}
|
||||
desc={t('settings.showChangelogOnUpdateDesc')}
|
||||
checked={auth.showChangelogOnUpdate}
|
||||
onChange={auth.setShowChangelogOnUpdate}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ThemeImportSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
type ModeFilter = 'all' | 'dark' | 'light';
|
||||
type SortMode = 'newest' | 'name';
|
||||
type AnimFilter = 'all' | 'animated' | 'static';
|
||||
|
||||
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function ThemeStoreSection() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<ModeFilter>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||
const [animFilter, setAnimFilter] = useState<AnimFilter>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
@@ -102,6 +104,8 @@ export function ThemeStoreSection() {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = themes.filter(th => {
|
||||
if (mode !== 'all' && th.mode !== mode) return false;
|
||||
if (animFilter === 'animated' && !th.animated) return false;
|
||||
if (animFilter === 'static' && th.animated) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
th.name.toLowerCase().includes(q) ||
|
||||
@@ -115,11 +119,11 @@ export function ThemeStoreSection() {
|
||||
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
|
||||
if (sortMode === 'name') return matched.sort(byName);
|
||||
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
|
||||
}, [themes, query, mode, sortMode]);
|
||||
}, [themes, query, mode, sortMode, animFilter]);
|
||||
|
||||
// A changed filter can shrink the result set below the current page; reset to
|
||||
// the first page whenever the query or mode filter changes.
|
||||
useEffect(() => { setPage(1); }, [query, mode, sortMode]);
|
||||
useEffect(() => { setPage(1); }, [query, mode, sortMode, animFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
|
||||
@@ -153,6 +157,12 @@ export function ThemeStoreSection() {
|
||||
{ value: 'name', label: t('settings.themeStoreSortName') },
|
||||
];
|
||||
|
||||
const animFilterOptions = [
|
||||
{ value: 'all', label: t('settings.themeStoreAnimAll') },
|
||||
{ value: 'animated', label: t('settings.themeStoreAnimAnimated') },
|
||||
{ value: 'static', label: t('settings.themeStoreAnimStatic') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
{/* Submit-your-own-theme hint */}
|
||||
@@ -202,6 +212,23 @@ export function ThemeStoreSection() {
|
||||
lineHeight: 1,
|
||||
}}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={animFilter}
|
||||
options={animFilterOptions}
|
||||
onChange={v => setAnimFilter(v as AnimFilter)}
|
||||
style={{
|
||||
width: 150,
|
||||
flexShrink: 0,
|
||||
alignSelf: 'stretch',
|
||||
boxSizing: 'border-box',
|
||||
padding: '0 var(--space-4)',
|
||||
background: 'var(--input-bg)',
|
||||
border: '1px solid var(--input-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
|
||||
{modeBtns.map(b => (
|
||||
<button
|
||||
@@ -324,6 +351,12 @@ export function ThemeStoreSection() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.themeStoreByAuthor', { author: th.author })}
|
||||
{' · '}
|
||||
{updateAvailable ? (
|
||||
<>v{inst!.version} <span style={{ color: 'var(--accent)' }}>→ v{th.version}</span></>
|
||||
) : (
|
||||
<>v{th.version}</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
|
||||
{th.description}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FIXED_THEMES } from './fixedThemes';
|
||||
import { InstalledThemes } from './InstalledThemes';
|
||||
import { ThemeImportSection } from './ThemeImportSection';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
|
||||
/**
|
||||
* A flat, always-visible section. The Themes tab has a single purpose, so its
|
||||
@@ -16,7 +17,16 @@ import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
* the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
|
||||
* search` keeps each section reachable from the global settings search.
|
||||
*/
|
||||
function ThemesSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
||||
function ThemesSection({ icon, title, children, boxed }: { icon: ReactNode; title: string; children: ReactNode; boxed?: boolean }) {
|
||||
if (boxed) {
|
||||
return (
|
||||
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup title={title} icon={icon}>{children}</SettingsGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 600, margin: '0 0 0.75rem' }}>
|
||||
@@ -39,7 +49,7 @@ export function ThemesTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')}>
|
||||
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')} boxed>
|
||||
{theme.enableThemeScheduler && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeSchedulerActiveHint')}
|
||||
@@ -48,8 +58,8 @@ export function ThemesTab() {
|
||||
<InstalledThemes />
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')}>
|
||||
<div className="settings-card">
|
||||
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')} boxed>
|
||||
<div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||||
@@ -114,7 +124,7 @@ export function ThemesTab() {
|
||||
</div>
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}>
|
||||
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')} boxed>
|
||||
<ThemeImportSection />
|
||||
</ThemesSection>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { AudioLines, RotateCcw } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { IS_MACOS } from '../../../utils/platform';
|
||||
import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels';
|
||||
|
||||
interface Props {
|
||||
@@ -19,8 +19,9 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio output device picker. macOS is hard-pinned to the system default,
|
||||
* so the picker collapses to a notice on that platform.
|
||||
* Audio output device picker. Not rendered on macOS — the audio stream is
|
||||
* pinned to the system default there, so the whole category is gated out by
|
||||
* the caller (`AudioTab`).
|
||||
*
|
||||
* The device switch is best-effort: if `audio_set_device` rejects (e.g.
|
||||
* device disappeared) we leave the previous selection in the store.
|
||||
@@ -43,49 +44,43 @@ export function AudioOutputDeviceSection({
|
||||
icon={<AudioLines size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{IS_MACOS ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
|
||||
{t('settings.audioOutputDeviceMacNotice')}
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.audioOutputDeviceDesc')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.audioOutputDeviceDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<CustomSelect
|
||||
style={{ flex: 1 }}
|
||||
value={audioOutputDevice ?? ''}
|
||||
disabled={deviceSwitching || devicesLoading}
|
||||
onChange={async (val) => {
|
||||
const device = val || null;
|
||||
setDeviceSwitching(true);
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName: device });
|
||||
setAudioOutputDevice(device);
|
||||
} catch { /* device open failed — don't persist */ }
|
||||
setDeviceSwitching(false);
|
||||
}}
|
||||
options={buildAudioDeviceSelectOptions(
|
||||
audioDevices,
|
||||
t('settings.audioOutputDeviceDefault'),
|
||||
osDefaultAudioDeviceId,
|
||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||
audioOutputDevice,
|
||||
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => refreshAudioDevices()}
|
||||
disabled={devicesLoading || deviceSwitching}
|
||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||
>
|
||||
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<CustomSelect
|
||||
style={{ flex: 1 }}
|
||||
value={audioOutputDevice ?? ''}
|
||||
disabled={deviceSwitching || devicesLoading}
|
||||
onChange={async (val) => {
|
||||
const device = val || null;
|
||||
setDeviceSwitching(true);
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName: device });
|
||||
setAudioOutputDevice(device);
|
||||
} catch { /* device open failed — don't persist */ }
|
||||
setDeviceSwitching(false);
|
||||
}}
|
||||
options={buildAudioDeviceSelectOptions(
|
||||
audioDevices,
|
||||
t('settings.audioOutputDeviceDefault'),
|
||||
osDefaultAudioDeviceId,
|
||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||
audioOutputDevice,
|
||||
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => refreshAudioDevices()}
|
||||
disabled={devicesLoading || deviceSwitching}
|
||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||
>
|
||||
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults';
|
||||
import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
|
||||
interface Props {
|
||||
preAnalysisEffectiveDb: number;
|
||||
@@ -23,18 +24,16 @@ interface Props {
|
||||
*
|
||||
* Switching engines clears the other engine's enabled flag so only one
|
||||
* can be live at a time.
|
||||
*
|
||||
* Rendered as its own top-level "Normalization" category in the Audio tab, so
|
||||
* the boxed `SettingsGroup` is title-less — the `SettingsSubSection` header and
|
||||
* description name it.
|
||||
*/
|
||||
export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.normalizationDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,6 +179,6 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfade ↔ Gapless are mutually exclusive — enabling one forces the
|
||||
* other off (`setGaplessEnabled(false)` / `setCrossfadeEnabled(false)`
|
||||
* on the toggle handlers) and the inactive row dims via opacity +
|
||||
* pointerEvents:none. The crossfade-seconds slider only renders while
|
||||
* crossfade is the active mode.
|
||||
*
|
||||
* The `preservePlayNextOrder` toggle is independent of both and pinned
|
||||
* to the bottom of the block.
|
||||
*/
|
||||
export function PlaybackBehaviorBlock({ t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||||
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
|
||||
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.gapless')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||||
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
|
||||
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginTop: '0.75rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.preservePlayNextOrder')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.preservePlayNextOrderDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
|
||||
<input type="checkbox" checked={auth.preservePlayNextOrder}
|
||||
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { usePlaybackRateStore } from '../../../store/playbackRateStore';
|
||||
import { useOrbitStore } from '../../../store/orbitStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -109,22 +110,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
|
||||
onWheel={compact ? handleWheelSpeed : undefined}
|
||||
>
|
||||
{showEnable && (
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.playbackRateEnabledDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.playbackRateEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.playbackRateEnabled')}
|
||||
desc={t('settings.playbackRateEnabledDesc')}
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!showEnable || enabled) && (
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuthStore } from '../../../store/authStore';
|
||||
import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults';
|
||||
import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -26,103 +28,96 @@ export function TrackPreviewsSection({ t }: Props) {
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.trackPreviewsToggle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.trackPreviewsDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.trackPreviewsToggle')}>
|
||||
<input type="checkbox" checked={auth.trackPreviewsEnabled}
|
||||
onChange={e => auth.setTrackPreviewsEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.trackPreviewsToggle')}
|
||||
desc={t('settings.trackPreviewsDesc')}
|
||||
checked={auth.trackPreviewsEnabled}
|
||||
onChange={auth.setTrackPreviewsEnabled}
|
||||
/>
|
||||
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
|
||||
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{t(`settings.trackPreviewLocation_${loc}`)}
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
|
||||
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{t(`settings.trackPreviewLocation_${loc}`)}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
|
||||
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
|
||||
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
|
||||
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
|
||||
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import {
|
||||
getTransitionMode,
|
||||
setTransitionMode,
|
||||
type TransitionMode,
|
||||
} from '../../../utils/playback/playbackTransition';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track-transition picker. Crossfade, AutoDJ and Gapless are mutually
|
||||
* exclusive — only one can be active — so they are presented as a single
|
||||
* `Off | Gapless | Crossfade | AutoDJ` segmented control backed by the shared
|
||||
* transition-mode helper.
|
||||
*
|
||||
* Classic crossfade exposes the seconds slider; AutoDJ is content-driven and
|
||||
* has no duration to configure (just a short explainer + the smooth-skip
|
||||
* toggle).
|
||||
*
|
||||
* Rendered as its own top-level "Track transitions" category in the Audio tab,
|
||||
* so the boxed `SettingsGroup` is title-less — the `SettingsSubSection` header
|
||||
* names it.
|
||||
*/
|
||||
export function TrackTransitionsBlock({ t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
const mode = getTransitionMode(auth);
|
||||
|
||||
const transitions: { id: TransitionMode; label: string }[] = [
|
||||
{ id: 'none', label: t('settings.transitionOff') },
|
||||
{ id: 'gapless', label: t('settings.gapless') },
|
||||
{ id: 'crossfade', label: t('settings.crossfade') },
|
||||
{ id: 'autodj', label: t('settings.autoDj') },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsGroup>
|
||||
<div className="settings-segmented">
|
||||
{transitions.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setTransitionMode(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'crossfade' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.7rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'autodj' && (
|
||||
<>
|
||||
<div style={{ paddingLeft: '1rem', fontSize: 12, color: 'var(--text-muted)', marginTop: '0.7rem' }}>
|
||||
{t('settings.autoDjDesc')}
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.7rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.autodjSmoothSkip')}
|
||||
desc={t('settings.autodjSmoothSkipDesc')}
|
||||
checked={auth.autodjSmoothSkip}
|
||||
onChange={auth.setAutodjSmoothSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -71,13 +71,13 @@ export function ConnectProviderForm({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{t('musicNetwork.addService')}</div>
|
||||
<div className="settings-group-title">{t('musicNetwork.addService')}</div>
|
||||
{available.map(preset => {
|
||||
const id = preset.manifest.presetId;
|
||||
const isExpanded = expanded === id;
|
||||
const isBusy = busy === id;
|
||||
return (
|
||||
<div key={id} className="settings-card" style={{ padding: '0.75rem 1rem' }}>
|
||||
<div key={id} className="settings-group" style={{ padding: '0.75rem 1rem', marginTop: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import type { Account } from '../../../music-network';
|
||||
|
||||
/**
|
||||
@@ -26,28 +27,24 @@ export function EnrichmentPrimarySelect({
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||
<SettingsGroup title={t('musicNetwork.primaryLabel')}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||
<CustomSelect
|
||||
value={primaryId ?? ''}
|
||||
options={options}
|
||||
onChange={v => onChange(v || null)}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={primaryId ?? ''}
|
||||
options={options}
|
||||
onChange={v => onChange(v || null)}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { showToast } from '../../../utils/ui/toast';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import {
|
||||
@@ -77,38 +78,24 @@ export function MusicNetworkSection() {
|
||||
{t('musicNetwork.desc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.masterToggle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||
<SettingsGroup title={t('musicNetwork.masterToggle')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<EnrichmentPrimarySelect
|
||||
accounts={accounts}
|
||||
primaryId={enrichmentPrimaryId}
|
||||
onChange={setPrimary}
|
||||
/>
|
||||
</div>
|
||||
<EnrichmentPrimarySelect
|
||||
accounts={accounts}
|
||||
primaryId={enrichmentPrimaryId}
|
||||
onChange={setPrimary}
|
||||
/>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{accounts.map(account => (
|
||||
<ScrobbleDestinationCard
|
||||
@@ -120,13 +107,13 @@ export function MusicNetworkSection() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MalojaProxyWarning accounts={accounts} />
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||
<div style={{ marginTop: 'var(--space-3)' }}>
|
||||
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
|
||||
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
|
||||
{ tab: 'audio', titleKey: 'settings.normalization', keywords: 'normalization normalisation loudness volume leveling level replaygain replay gain lufs pre-gain' },
|
||||
{ tab: 'audio', titleKey: 'settings.transitionsTitle', keywords: 'track transitions crossfade autodj auto dj smart crossfade gapless blend fade trim silence' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
|
||||
{ tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' },
|
||||
@@ -47,7 +48,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'mainstage home page customize sections' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.queueSettingsTitle', keywords: 'queue settings display mode list playlist timeline toolbar buttons reorder customize shuffle save load behaviour behavior preserve play next order' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
|
||||
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
|
||||
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
|
||||
@@ -70,7 +71,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
||||
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
||||
{ tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
|
||||
{ tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' },
|
||||
{ tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux swipe back forward touchpad gesture navigation' },
|
||||
{ tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' },
|
||||
{ tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
|
||||
{ tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Sparkles } from 'lucide-react';
|
||||
import { AudioLines, ChevronRight, HardDriveDownload, Settings } from 'lucide-react';
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import WhatsNewBanner from '../WhatsNewBanner';
|
||||
import ThemeUpdateBanner from '../ThemeUpdateBanner';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import SidebarLibraryPicker from './SidebarLibraryPicker';
|
||||
import SidebarPlaylistsSection from './SidebarPlaylistsSection';
|
||||
import SidebarActiveJobs from './SidebarActiveJobs';
|
||||
|
||||
interface NavDndState {
|
||||
@@ -146,26 +146,7 @@ export default function SidebarNavBody(props: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{playlistsExpanded && (
|
||||
<div className="sidebar-playlists-list">
|
||||
{playlistsLoading ? (
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
playlists.map((pl: { id: string; name: string }) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<SidebarPlaylistsSection playlists={playlists} playlistsLoading={playlistsLoading} />
|
||||
)}
|
||||
</div>
|
||||
) : isCollapsed ? (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, Folder, PlayCircle, Sparkles } from 'lucide-react';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
import { groupPlaylistsByFolder } from '../../utils/playlist/playlistFolders';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
playlists: SidebarPlaylist[];
|
||||
playlistsLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar playlist list, grouped into collapsible folders when the active
|
||||
* server has any. Folder state comes from the shared local folder store;
|
||||
* creation / rename / deletion lives on the Playlists page, while assignment
|
||||
* works here via each playlist's right-click menu ("Move to folder"). With no
|
||||
* folders this renders the original flat list (plus right-click support).
|
||||
*/
|
||||
export default function SidebarPlaylistsSection({ playlists, playlistsLoading }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const fullPlaylists = usePlaylistStore(s => s.playlists);
|
||||
const bucket =
|
||||
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
|
||||
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
|
||||
|
||||
if (playlistsLoading) {
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (playlists.length === 0) {
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = (pl: SidebarPlaylist) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
const full = fullPlaylists.find(p => p.id === pl.id) ?? pl;
|
||||
openContextMenu(e.clientX, e.clientY, full, 'playlist');
|
||||
}}
|
||||
>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
if (!serverId || bucket.folders.length === 0) {
|
||||
return <div className="sidebar-playlists-list">{playlists.map(renderItem)}</div>;
|
||||
}
|
||||
|
||||
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
|
||||
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
{grouped.folders.map(({ folder, playlists: items }) => (
|
||||
<div key={folder.id} className="sidebar-playlist-folder">
|
||||
<button
|
||||
className={`sidebar-playlist-folder-header${folder.collapsed ? '' : ' expanded'}`}
|
||||
onClick={() => toggleFolderCollapsed(serverId, folder.id)}
|
||||
aria-expanded={!folder.collapsed}
|
||||
aria-label={folder.collapsed ? t('playlists.folders.expandFolder') : t('playlists.folders.collapseFolder')}
|
||||
>
|
||||
<ChevronRight size={12} className="sidebar-playlist-folder-chevron" />
|
||||
<Folder size={12} />
|
||||
<span className="sidebar-playlist-folder-name">{folder.name}</span>
|
||||
<span className="sidebar-playlist-folder-count">{items.length}</span>
|
||||
</button>
|
||||
{!folder.collapsed && items.length > 0 && (
|
||||
<div className="sidebar-playlist-folder-items">{items.map(renderItem)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{grouped.ungrouped.map(renderItem)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,11 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)',
|
||||
'OpenSubsonic playbackReport — live now-playing state, gliding position bar, and immediate pause/resume on Navidrome ≥0.62 (PR #1080)',
|
||||
'Playback speed follow-up — Semitones varispeed strategy, two-decimal speed label, per-strategy tooltips, and Advanced fine-step toggle (PR #1084)',
|
||||
'Streamed Opus/Ogg seeking via on-demand HTTP Range fetches — seek mid-stream without a full pre-download (PR #1110)',
|
||||
'AutoDJ — content-aware crossfade: waveform-driven silence trim, content-driven overlap, scenario-A self-fade, readiness gate + engine auto-crossfade suppression (PR #1122)',
|
||||
'AutoDJ — smooth skip and interrupt blend: manual/out-of-queue crossfade, loud→loud ~2s advance, cold-target prep duck + deferred player-bar handoff (PR #1128)',
|
||||
'Play queue sync — manual pull via connection indicator, idle auto-pull, multi-server push filter, flush-on-server-switch (PR #1131)',
|
||||
'Niri compositor tiling WM detection (PR #1127)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -370,6 +375,8 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
|
||||
'AutoDJ as a standalone playback mode — own toolbar button and audio-settings entry, mutually exclusive with Crossfade and Gapless via a shared transition helper, combined playlist save/load submenu, and boxed playback settings sections (PR #1124)',
|
||||
'Settings refactor — reusable boxed panels (SettingsGroup/SettingsToggle) rolled across every tab, Normalization and Track transitions promoted to their own Audio categories, a unified Queue Settings group on Personalisation, and the Audio Output Device category gated out on macOS (PR #1126, #1130)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* coverArtUrlForDiscord — Discord fetches the large image from its own servers,
|
||||
* so the URL must use the public address, not the LAN-preferred connect URL
|
||||
* (regression from the dual-address feature).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { coverArtUrlForDiscord } from './discord';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
function refForServer(serverId: string, url: string): CoverArtRef {
|
||||
return {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'server', serverId, url, username: 'tester', password: 'pw' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('coverArtUrlForDiscord', () => {
|
||||
it('uses the public address on a dual-address profile, not the LAN one', async () => {
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
|
||||
it('returns the single configured address when there is no alternate', async () => {
|
||||
const server = makeServer({ url: 'https://music.example.com', alternateUrl: undefined });
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
});
|
||||
|
||||
it('resolves a playback scope to the active profile (public address)', async () => {
|
||||
// playback scope is the common case for locally-cached tracks; it must
|
||||
// resolve to the active server, not yield a null cover.
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const ref: CoverArtRef = {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'playback' },
|
||||
};
|
||||
const url = await coverArtUrlForDiscord(ref);
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,52 @@
|
||||
import { buildCoverArtFetchUrl } from '../fetchUrl';
|
||||
import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl';
|
||||
import { serverShareBaseUrl } from '../../utils/server/serverEndpoint';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
/**
|
||||
* Discord large image — always the HTTPS fetch URL, never a local cache path.
|
||||
* Discord Rich Presence images are fetched by Discord's own servers, so the
|
||||
* large_image must be a key or an https:// URL they can reach. A `file://` path
|
||||
* to the on-disk webp cache (what MPRIS uses) is meaningless to Discord and
|
||||
* silently falls back to the app icon — so we hand it the getCoverArt URL.
|
||||
* Discord large image — an https:// URL Discord's own servers can reach.
|
||||
*
|
||||
* Unlike every other cover fetch we must NOT use the connect URL: that prefers
|
||||
* the LAN address (fast for the app itself), but Discord fetches the image
|
||||
* remotely, so a `http://192.168.x.x` address is unreachable and falls back to
|
||||
* the app icon. Discord is an external consumer just like a share link, so use
|
||||
* `serverShareBaseUrl` (public address preferred when both are set).
|
||||
*
|
||||
* Resolve the profile straight from the store: a `playback`/`active` scope
|
||||
* always means the active server (a cross-server track gets an explicit
|
||||
* `server` scope), so we never route through `getPlaybackServerId()`, whose
|
||||
* empty-string / index-key returns previously yielded a null cover URL on
|
||||
* locally-cached tracks.
|
||||
*/
|
||||
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
|
||||
return buildCoverArtFetchUrl(ref, 800) || null;
|
||||
const { serverScope, fetchCoverArtId } = ref;
|
||||
const auth = useAuthStore.getState();
|
||||
|
||||
const profile =
|
||||
serverScope.kind === 'server'
|
||||
? auth.servers.find(s => s.id === serverScope.serverId)
|
||||
: auth.servers.find(s => s.id === auth.activeServerId);
|
||||
|
||||
if (profile) {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl(profile),
|
||||
profile.username,
|
||||
profile.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
// Server scope carries its own URL/creds even when not a saved profile.
|
||||
if (serverScope.kind === 'server') {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl({ url: serverScope.url }),
|
||||
serverScope.username,
|
||||
serverScope.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user