Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cd4cdcd64 | |||
| a32ca64792 | |||
| 939abace35 | |||
| 4f7236e986 | |||
| 9be0d8dfa9 | |||
| 67f31b0700 | |||
| c873880a26 | |||
| 463b7483fd | |||
| 3d11ef91a1 | |||
| c365140870 | |||
| 651b3cb050 | |||
| e2ee9247ad | |||
| 74df7b6b88 | |||
| 27a6693c8c | |||
| a932e7c2db | |||
| 7263d93d42 | |||
| 95283d792b | |||
| 53d5888ebf | |||
| cad4338324 | |||
| f9bc67cb77 | |||
| 74c75d83ca | |||
| 005abae97d | |||
| a9c20dfbdf | |||
| 0b5db172bd | |||
| bf99a64baf | |||
| 523596e414 | |||
| 2fe35e3f9b | |||
| e9fa541933 | |||
| 746aa69405 | |||
| 205b2c1914 | |||
| 0086b3e310 | |||
| 55e7cb835b | |||
| d8da511a8f | |||
| 434ee0ecf1 | |||
| 7d1c66071e | |||
| 1adfda1daa | |||
| e65c476a76 | |||
| 560349819f | |||
| ada5327493 | |||
| c67d606f89 | |||
| 662cc94ca8 | |||
| 1eacaf678c | |||
| 4a8fb64c66 | |||
| 43c656dfc3 | |||
| 74b519f9f5 | |||
| 3d03b8d5a1 | |||
| d6f6e6466c | |||
| 95cdbc7fc7 | |||
| 42863877f6 | |||
| 7ed0fa4914 | |||
| 4f8e7d7bc7 | |||
| bb56269cd1 | |||
| 29a4363dca | |||
| e1d27798eb | |||
| b35539d3cf | |||
| a1b3022140 | |||
| b6fb66c46d | |||
| 936e548f40 | |||
| b67c198227 | |||
| 65a828e3fa | |||
| 6bdd6f3a59 | |||
| d62bffd082 | |||
| ff706104ab | |||
| 0abef4b266 | |||
| 3effad0830 | |||
| 361e9cfdb3 | |||
| 5516d95b52 | |||
| d927ef2082 | |||
| 867c5fbd3e | |||
| e550340565 | |||
| 57b70e6154 | |||
| 0b1ed8cc5a | |||
| c9c68a0e57 | |||
| 9d4997baac | |||
| a99e2e0657 | |||
| 7f85b587b4 | |||
| c8d5e9c028 | |||
| 2ba7845c79 | |||
| 9400a5fb2b | |||
| 0e88e8a5cd | |||
| 7de4b97df0 | |||
| 59115a09d2 | |||
| 18199a1f8a | |||
| d4e44199e9 | |||
| 9b4eb0982c | |||
| 8ec642f368 | |||
| 73f836b2ee | |||
| af18aef42a | |||
| d3ffa30bf5 | |||
| f666f84479 | |||
| c91fdd7e1d | |||
| 9bdd433a4b | |||
| a5fd70d3eb | |||
| 744775d3a1 |
@@ -0,0 +1,12 @@
|
||||
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
|
||||
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
|
||||
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
|
||||
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
|
||||
# bfd is always available via binutils (part of base-devel on Arch).
|
||||
#
|
||||
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
|
||||
# overrides target-specific rustflags here. The PKGBUILD's build() applies
|
||||
# the same fix by patching the RUSTFLAGS env var directly.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "cc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
@@ -19,31 +19,70 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
# Extract the block between ## [VERSION] and the next ## heading
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Store multiline output
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "$EOF" >> $GITHUB_OUTPUT
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
with:
|
||||
script: |
|
||||
const tag = `app-v${process.env.PACKAGE_VERSION}`;
|
||||
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'See the assets to download this version and install.',
|
||||
draft: false,
|
||||
body,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
})
|
||||
return data.id
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -53,7 +92,7 @@ jobs:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
args: '--bundles nsis'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -61,24 +100,71 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: app-v${{ needs.create-release.outputs.package_version }}
|
||||
releaseName: Psysonic v${{ needs.create-release.outputs.package_version }}
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: sign and upload Windows NSIS updater bundle
|
||||
if: matrix.settings.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
$dir = "src-tauri/target/release/bundle/nsis"
|
||||
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
|
||||
$zip = $exe -replace '\.exe$', '.nsis.zip'
|
||||
Compress-Archive -Path $exe -DestinationPath $zip -Force
|
||||
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
|
||||
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
|
||||
|
||||
- name: sign and upload macOS bundle signature
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
|
||||
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
|
||||
else
|
||||
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
|
||||
fi
|
||||
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
|
||||
cp "${BUNDLE}.sig" "$SIG_NAME"
|
||||
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -87,42 +173,36 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
- name: install linuxdeploy gstreamer plugin
|
||||
run: |
|
||||
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
|
||||
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
libasound2-dev
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
run: npm run tauri:build
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
|
||||
- name: patch AppImage AppRun
|
||||
run: |
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
|
||||
echo "Patching: $APPIMAGE"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
|
||||
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
|
||||
-O appimagetool
|
||||
chmod +x appimagetool
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root appimagetool
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -130,5 +210,34 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
@@ -7,6 +7,11 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment variables (API keys)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
dist
|
||||
@@ -29,3 +34,9 @@ src-tauri/target/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2026 Psychotoxical
|
||||
Copyright (C) 2026 Psychotoxical
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The full license text is available at:
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic.
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
|
||||
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
@@ -21,31 +22,99 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
|
||||
- 🔄 **Update Notifications**: Built-in update checker that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
## ● Known Limitations
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- **Seeking (all platforms)**: Seeking may occasionally be unreliable — especially on Linux/GStreamer. This is a known issue currently being worked on.
|
||||
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop is being actively worked on and may not always behave correctly on macOS and Windows.
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Waveform seekbar
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
- [x] Advanced Search (text + genre + year + result-type filters)
|
||||
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
### 🐧 Linux
|
||||
|
||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
||||
|
||||
### 🍎 macOS
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux**: `.AppImage` or `.deb`
|
||||
|
||||
> [!WARNING]
|
||||
> **Gatekeeper Note:**
|
||||
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
|
||||
> ```sh
|
||||
> xattr -cr /Applications/Psysonic.app
|
||||
> ```
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
|
||||
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
|
||||
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -92,4 +161,6 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
|
||||
## 📄 License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "0.1.0",
|
||||
"version": "1.30.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "0.1.0",
|
||||
"version": "1.30.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"howler": "^2.2.4",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -28,7 +30,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
@@ -1226,6 +1227,33 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
@@ -1495,10 +1523,10 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
@@ -1522,6 +1550,24 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||
"integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1574,13 +1620,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/howler": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
|
||||
"integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/md5": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
|
||||
@@ -2110,12 +2149,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/howler": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
|
||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -2307,9 +2340,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.12",
|
||||
"version": "1.33.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,15 +11,17 @@
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"howler": "^2.2.4",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -31,7 +33,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
src/
|
||||
pkg/
|
||||
*.tar.zst
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,80 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.33.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Psychotoxical/psysonic"
|
||||
license=('GPL-3.0-only')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'openssl'
|
||||
'alsa-lib'
|
||||
)
|
||||
makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Psysonic
|
||||
Comment=Desktop music player for Subsonic API-compatible servers
|
||||
Exec=psysonic
|
||||
Icon=psysonic
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
EOF
|
||||
|
||||
# Icons
|
||||
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="115.549mm"
|
||||
height="130.30972mm"
|
||||
viewBox="0 0 115.549 130.30972"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="p-small.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"><inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
width="115.549"
|
||||
height="130.30972"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
style="fill:#ffffff"
|
||||
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
|
||||
id="path1" /></g></svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
|
||||
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
|
||||
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pull release notes from GitHub
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.12"
|
||||
version = "1.33.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -22,11 +22,24 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Psysonic is a music PLAYER only — no microphone access needed.
|
||||
This suppresses the macOS microphone permission prompt triggered
|
||||
by cpal/CoreAudio enumerating input devices during audio init. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Psysonic is a music player only — it does not record audio.
|
||||
This description is shown if macOS prompts for microphone access
|
||||
(triggered by CoreAudio enumerating input devices during init). -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
|
||||
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -7,8 +7,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
"notification:default",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"store:default",
|
||||
@@ -18,15 +17,24 @@
|
||||
"store:allow-save",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6015,202 +6015,34 @@
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
@@ -6451,6 +6283,102 @@
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
"const": "window-state:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-filename",
|
||||
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-restore-state",
|
||||
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-save-window-state",
|
||||
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-filename",
|
||||
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-restore-state",
|
||||
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-save-window-state",
|
||||
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6015,202 +6015,34 @@
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
@@ -6451,6 +6283,102 @@
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
"const": "window-state:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-filename",
|
||||
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-restore-state",
|
||||
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:allow-save-window-state",
|
||||
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the filename command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-filename",
|
||||
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-restore-state",
|
||||
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "window-state:deny-save-window-state",
|
||||
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 985 KiB After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,105 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// To enable this feature:
|
||||
/// 1. Go to https://discord.com/developers/applications and create an application.
|
||||
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
|
||||
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
|
||||
/// (use the app icon from public/logo.png).
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, ActivityType, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState(Mutex::new(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
|
||||
client.connect().ok()?;
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
|
||||
/// the song/artist without a running timer.
|
||||
#[tauri::command]
|
||||
pub fn discord_update_presence(
|
||||
state: tauri::State<DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
elapsed_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
// Discord RPC only exposes two visible text rows (details + state).
|
||||
// The application name "Psysonic" is shown automatically by Discord as the
|
||||
// header line. Album goes into large_text — visible as a hover tooltip on
|
||||
// the cover art icon.
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
|
||||
let assets = Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text);
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.details(&title)
|
||||
.state(&artist)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,31 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
type ShortcutMap = Mutex<HashMap<String, String>>;
|
||||
|
||||
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
|
||||
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
|
||||
type TrayState = Mutex<Option<TrayIcon>>;
|
||||
|
||||
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
|
||||
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
@@ -17,97 +36,839 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Restart after an in-app update.
|
||||
///
|
||||
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
|
||||
/// is still alive, so the single-instance plugin in the new process sees the old
|
||||
/// socket and kills itself immediately (endless loop).
|
||||
///
|
||||
/// This command instead:
|
||||
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
|
||||
/// by then the old process and its single-instance socket are fully gone.
|
||||
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
|
||||
/// prevent_close() and releases the single-instance lock immediately.
|
||||
#[tauri::command]
|
||||
fn relaunch_after_update(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
|
||||
let script = format!(
|
||||
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
|
||||
let bundle = exe
|
||||
.parent() // MacOS/
|
||||
.and_then(|p| p.parent()) // Contents/
|
||||
.and_then(|p| p.parent()); // Psysonic.app
|
||||
if let Some(bundle_path) = bundle {
|
||||
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
data["token"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn upload_playlist_cover(
|
||||
server_url: String,
|
||||
playlist_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn upload_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn upload_artist_image(
|
||||
server_url: String,
|
||||
artist_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 404/503 = no image existed — treat as success
|
||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const RADIO_PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
||||
#[tauri::command]
|
||||
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let limit_s = RADIO_PAGE_SIZE.to_string();
|
||||
let offset_s = offset.to_string();
|
||||
let resp = client
|
||||
.get("https://de1.api.radio-browser.info/json/stations/search")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.query(&[
|
||||
("name", query.as_str()),
|
||||
("hidebroken", "true"),
|
||||
("limit", limit_s.as_str()),
|
||||
("offset", offset_s.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
|
||||
#[tauri::command]
|
||||
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let limit_s = RADIO_PAGE_SIZE.to_string();
|
||||
let offset_s = offset.to_string();
|
||||
let resp = client
|
||||
.get("https://de1.api.radio-browser.info/json/stations/topvote")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
|
||||
/// Returns (bytes, content_type).
|
||||
#[tauri::command]
|
||||
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("image/jpeg")
|
||||
.trim()
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok((bytes.to_vec(), content_type))
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
#[tauri::command]
|
||||
async fn lastfm_request(
|
||||
params: Vec<[String; 2]>,
|
||||
sign: bool,
|
||||
get: bool,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
|
||||
map.insert("api_key".into(), api_key.clone());
|
||||
|
||||
if sign {
|
||||
let mut keys: Vec<String> = map.keys().cloned().collect();
|
||||
keys.sort();
|
||||
let sig_str: String = keys.iter()
|
||||
.filter(|k| k.as_str() != "format" && k.as_str() != "callback")
|
||||
.map(|k| format!("{}{}", k, map[k]))
|
||||
.collect::<String>();
|
||||
let sig_input = format!("{}{}", sig_str, api_secret);
|
||||
let digest = md5::compute(sig_input.as_bytes());
|
||||
map.insert("api_sig".into(), format!("{:x}", digest));
|
||||
}
|
||||
|
||||
map.insert("format".into(), "json".into());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = if get {
|
||||
client
|
||||
.get("https://ws.audioscrobbler.com/2.0/")
|
||||
.query(&map)
|
||||
.header("User-Agent", "psysonic/1.13.0")
|
||||
.send()
|
||||
.await
|
||||
} else {
|
||||
client
|
||||
.post("https://ws.audioscrobbler.com/2.0/")
|
||||
.form(&map)
|
||||
.header("User-Agent", "psysonic/1.13.0")
|
||||
.send()
|
||||
.await
|
||||
}.map_err(|e| e.to_string())?;
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(err) = json.get("error") {
|
||||
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
|
||||
}
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
fn register_global_shortcut(
|
||||
app: tauri::AppHandle,
|
||||
shortcut_map: tauri::State<ShortcutMap>,
|
||||
shortcut: String,
|
||||
action: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
let mut map = shortcut_map.lock().unwrap();
|
||||
|
||||
// Idempotent: if this exact shortcut+action is already registered, skip.
|
||||
// This prevents on_shortcut() from accumulating duplicate handlers when
|
||||
// registerAll() is called again after a JS HMR reload or StrictMode double-effect.
|
||||
if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Unregister any existing OS grab for this shortcut before re-registering.
|
||||
if let Ok(s) = shortcut.parse::<Shortcut>() {
|
||||
let _ = app.global_shortcut().unregister(s);
|
||||
}
|
||||
map.insert(shortcut.clone(), action.clone());
|
||||
drop(map); // release lock before the blocking OS call
|
||||
|
||||
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
|
||||
app.global_shortcut()
|
||||
.on_shortcut(parsed, move |app, _shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let event_name = match action.as_str() {
|
||||
"play-pause" => "media:play-pause",
|
||||
"next" => "media:next",
|
||||
"prev" => "media:prev",
|
||||
"volume-up" => "media:volume-up",
|
||||
"volume-down" => "media:volume-down",
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.emit(event_name, ());
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn unregister_global_shortcut(
|
||||
app: tauri::AppHandle,
|
||||
shortcut_map: tauri::State<ShortcutMap>,
|
||||
shortcut: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
|
||||
shortcut_map.lock().unwrap().remove(&shortcut);
|
||||
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
|
||||
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn mpris_set_metadata(
|
||||
controls: tauri::State<MprisControls>,
|
||||
title: Option<String>,
|
||||
artist: Option<String>,
|
||||
album: Option<String>,
|
||||
cover_url: Option<String>,
|
||||
duration_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
use souvlaki::MediaMetadata;
|
||||
use std::time::Duration;
|
||||
|
||||
let duration = duration_secs.map(|s| Duration::from_secs_f64(s));
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
ctrl.set_metadata(MediaMetadata {
|
||||
title: title.as_deref(),
|
||||
artist: artist.as_deref(),
|
||||
album: album.as_deref(),
|
||||
cover_url: cover_url.as_deref(),
|
||||
duration,
|
||||
})
|
||||
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn mpris_set_playback(
|
||||
controls: tauri::State<MprisControls>,
|
||||
playing: bool,
|
||||
position_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
use souvlaki::{MediaPlayback, MediaPosition};
|
||||
use std::time::Duration;
|
||||
|
||||
let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s)));
|
||||
let playback = if playing {
|
||||
MediaPlayback::Playing { progress }
|
||||
} else {
|
||||
MediaPlayback::Paused { progress }
|
||||
};
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
ctrl.set_playback(playback)
|
||||
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
|
||||
}
|
||||
|
||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||
|
||||
/// Downloads a single track to the app's offline cache directory.
|
||||
/// Returns the absolute file path so TypeScript can store it and later
|
||||
/// construct a `psysonic-local://<path>` URL for the audio engine.
|
||||
#[tauri::command]
|
||||
async fn download_track_offline(
|
||||
track_id: String,
|
||||
server_id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
custom_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
// Determine base cache directory.
|
||||
let cache_dir = if let Some(ref cd) = custom_dir {
|
||||
let base = std::path::PathBuf::from(cd);
|
||||
// Check that the volume/directory is still accessible.
|
||||
if !base.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
base.join(&server_id)
|
||||
} else {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
.join(&server_id)
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all(&cache_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// Already cached — skip re-download.
|
||||
if file_path.exists() {
|
||||
return Ok(path_str);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
tokio::fs::write(&file_path, &bytes)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
|
||||
#[tauri::command]
|
||||
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
|
||||
fn dir_size(root: std::path::PathBuf) -> u64 {
|
||||
if !root.exists() { return 0; }
|
||||
let mut total: u64 = 0;
|
||||
let mut stack = vec![root];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(meta) = std::fs::metadata(&path) {
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
let default_dir = match app.path().app_data_dir() {
|
||||
Ok(d) => d.join("psysonic-offline"),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut total = dir_size(default_dir);
|
||||
|
||||
if let Some(cd) = custom_dir {
|
||||
let custom = std::path::PathBuf::from(cd);
|
||||
if custom != std::path::PathBuf::from("") {
|
||||
total += dir_size(custom);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Removes a cached track from the offline cache. Accepts the full local path
|
||||
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
|
||||
/// After deleting the file, empty parent directories up to (but not including)
|
||||
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
|
||||
#[tauri::command]
|
||||
async fn delete_offline_track(
|
||||
local_path: String,
|
||||
base_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let file_path = std::path::PathBuf::from(&local_path);
|
||||
if file_path.exists() {
|
||||
tokio::fs::remove_file(&file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Determine the safe boundary — never delete at or above this directory.
|
||||
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
|
||||
std::path::PathBuf::from(bd)
|
||||
} else {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
};
|
||||
|
||||
// Walk upward, pruning directories that have become empty.
|
||||
// Stops as soon as a non-empty directory or the boundary is reached.
|
||||
let mut current = file_path.parent().map(|p| p.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
if dir == boundary || !dir.starts_with(&boundary) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(mut entries) => {
|
||||
if entries.next().is_some() {
|
||||
break; // Directory still has contents — stop pruning.
|
||||
}
|
||||
if std::fs::remove_dir(&dir).is_err() {
|
||||
break; // Could not remove (e.g. permissions) — stop.
|
||||
}
|
||||
current = dir.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds and returns a new system-tray icon with all menu items and event handlers.
|
||||
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
|
||||
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
|
||||
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&play_pause)
|
||||
.item(&previous)
|
||||
.item(&next)
|
||||
.item(&sep1)
|
||||
.item(&show_hide)
|
||||
.item(&sep2)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Psysonic")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
|
||||
"next" => { let _ = app.emit("tray:next", ()); }
|
||||
"previous" => { let _ = app.emit("tray:previous", ()); }
|
||||
"show_hide" => {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => { stop_audio_engine(app); app.exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event {
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
}
|
||||
|
||||
/// Show (`true`) or fully remove (`false`) the system-tray icon.
|
||||
///
|
||||
/// The command is strictly idempotent:
|
||||
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
|
||||
/// - `show=false` when the icon is already absent → no-op.
|
||||
///
|
||||
/// For removal, `set_visible(false)` is called explicitly before the handle is
|
||||
/// dropped because some platforms (Windows notification area, certain Linux DEs)
|
||||
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
|
||||
/// icon from appearing alongside a freshly created one.
|
||||
#[tauri::command]
|
||||
fn toggle_tray_icon(
|
||||
app: tauri::AppHandle,
|
||||
tray_state: tauri::State<TrayState>,
|
||||
show: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = tray_state.lock().unwrap();
|
||||
|
||||
if show {
|
||||
// Early-return when already shown — never build a second icon.
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
|
||||
} else if let Some(tray) = guard.take() {
|
||||
// Hide synchronously before dropping so the OS processes the removal
|
||||
// before any subsequent show=true call can create a new icon.
|
||||
let _ = tray.set_visible(false);
|
||||
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
|
||||
/// Called before process exit on macOS to ensure audio stops immediately.
|
||||
fn stop_audio_engine(app: &tauri::AppHandle) {
|
||||
let engine = app.state::<audio::AudioEngine>();
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*engine.chained_info.lock().unwrap() = None;
|
||||
drop(engine.radio_state.lock().unwrap().take());
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.manage(TrayState::default())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// Build tray menu
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
|
||||
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
|
||||
let show = MenuItemBuilder::with_id("show", "Show Psysonic").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Exit").build(app)?;
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
// Always build on startup; the frontend calls toggle_tray_icon(false)
|
||||
// immediately after load if the user has disabled the tray icon.
|
||||
{
|
||||
let tray = build_tray_icon(app.handle())?;
|
||||
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
||||
}
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&play_pause)
|
||||
.item(&next)
|
||||
.item(&separator)
|
||||
.item(&show)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||
{
|
||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Psysonic")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"play_pause" => {
|
||||
let _ = app.emit("tray:play-pause", ());
|
||||
}
|
||||
"next" => {
|
||||
let _ = app.emit("tray:next", ());
|
||||
}
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
// Collect pre-conditions and the platform-specific HWND.
|
||||
// Returns None early (with a log) on any unrecoverable condition
|
||||
// so app.manage() always executes exactly once at the bottom.
|
||||
let maybe_controls: Option<MediaControls> = (|| {
|
||||
// Linux: requires a live D-Bus session.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false);
|
||||
if !dbus_ok {
|
||||
eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|_tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Left click shows app (handled in JS side via tray event)
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Register media key global shortcuts
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
|
||||
for shortcut_str in &shortcuts {
|
||||
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
|
||||
let shortcut_clone = shortcut_str.to_string();
|
||||
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let event_name = match shortcut_clone.as_str() {
|
||||
"MediaPlayPause" => "media:play-pause",
|
||||
"MediaNextTrack" => "media:next",
|
||||
"MediaPreviousTrack" => "media:prev",
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.emit(event_name, ());
|
||||
// Windows: souvlaki SMTC must hook into the existing Win32
|
||||
// message loop rather than spinning up its own. Pass the
|
||||
// main window's HWND so it can do so. If we can't get one,
|
||||
// skip init (no crash, just no media overlay).
|
||||
#[cfg(target_os = "windows")]
|
||||
let hwnd = {
|
||||
use tauri::Manager;
|
||||
let h = app.get_webview_window("main")
|
||||
.and_then(|w| w.hwnd().ok())
|
||||
.map(|h| h.0 as *mut std::ffi::c_void);
|
||||
if h.is_none() {
|
||||
eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
|
||||
return None;
|
||||
}
|
||||
h
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd: Option<*mut std::ffi::c_void> = None;
|
||||
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "psysonic",
|
||||
display_name: "Psysonic",
|
||||
hwnd,
|
||||
};
|
||||
|
||||
match MediaControls::new(config) {
|
||||
Ok(mut controls) => {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
match event {
|
||||
MediaControlEvent::Toggle
|
||||
| MediaControlEvent::Play
|
||||
| MediaControlEvent::Pause => {
|
||||
let _ = app_handle.emit("media:play-pause", ());
|
||||
}
|
||||
MediaControlEvent::Next => {
|
||||
let _ = app_handle.emit("media:next", ());
|
||||
}
|
||||
MediaControlEvent::Previous => {
|
||||
let _ = app_handle.emit("media:prev", ());
|
||||
}
|
||||
MediaControlEvent::Seek(direction) => {
|
||||
use souvlaki::SeekDirection;
|
||||
let delta: f64 = match direction {
|
||||
SeekDirection::Forward => 5.0,
|
||||
SeekDirection::Backward => -5.0,
|
||||
};
|
||||
let _ = app_handle.emit("media:seek-relative", delta);
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => {
|
||||
let secs = pos.0.as_secs_f64();
|
||||
let _ = app_handle.emit("media:seek-absolute", secs);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}) {
|
||||
eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
|
||||
}
|
||||
});
|
||||
Some(controls)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
app.manage(MprisControls::new(maybe_controls));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
// Emit event so JS can decide: hide to tray or allow close.
|
||||
// JS handles prevent_close via onCloseRequested() in App.tsx.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
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.
|
||||
// Stop the audio engine first so sound cuts immediately.
|
||||
let app = window.app_handle();
|
||||
stop_audio_engine(app);
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![greet, exit_app])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
mpris_set_metadata,
|
||||
mpris_set_playback,
|
||||
audio::audio_play,
|
||||
audio::audio_pause,
|
||||
audio::audio_resume,
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
audio::audio_update_replay_gain,
|
||||
audio::audio_set_eq,
|
||||
audio::autoeq_entries,
|
||||
audio::autoeq_fetch_profile,
|
||||
audio::audio_preload,
|
||||
audio::audio_play_radio,
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_chain_preload,
|
||||
discord::discord_update_presence,
|
||||
discord::discord_clear_presence,
|
||||
lastfm_request,
|
||||
upload_playlist_cover,
|
||||
upload_radio_cover,
|
||||
upload_artist_image,
|
||||
delete_radio_cover,
|
||||
search_radio_browser,
|
||||
get_top_radio_stations,
|
||||
fetch_url_bytes,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
relaunch_after_update,
|
||||
toggle_tray_icon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.0.12",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"version": "1.33.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -16,23 +16,27 @@
|
||||
"title": "Psysonic",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true
|
||||
"visible": true,
|
||||
"dragDropEnabled": false,
|
||||
"devtools": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": false,
|
||||
"title": "Psysonic",
|
||||
"tooltip": "Psysonic"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -53,9 +57,13 @@
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { showToast } from './utils/toast';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
@@ -20,15 +23,40 @@ import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore } from './store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -37,17 +65,44 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevConnStatus.current;
|
||||
prevConnStatus.current = connStatus;
|
||||
|
||||
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
|
||||
navigate('/offline', { replace: true });
|
||||
}
|
||||
// Return from offline page only when reconnecting (not when user navigates there manually while online)
|
||||
if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [connStatus, hasOfflineContent, location.pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
}, [initializeFromServerQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
useEqStore.getState().syncToRust();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
try {
|
||||
@@ -66,10 +121,19 @@ function AppShell() {
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
|
||||
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
|
||||
setChangelogModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
});
|
||||
const [queueWidth, setQueueWidth] = useState(300);
|
||||
const [queueWidth, setQueueWidth] = useState(340);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +142,7 @@ function AppShell() {
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDraggingQueue) {
|
||||
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
|
||||
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
|
||||
setQueueWidth(newWidth);
|
||||
}
|
||||
}, [isDraggingQueue]);
|
||||
@@ -106,26 +170,95 @@ function AppShell() {
|
||||
};
|
||||
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// ── Global DnD fix for Linux/WebKitGTK ──────────────────────────
|
||||
// WebKitGTK (used by Tauri on Linux) requires the document itself to
|
||||
// accept drags via preventDefault() on dragover/dragenter. Without
|
||||
// this, the webview shows a "forbidden" cursor for all in-app HTML5
|
||||
// drag-and-drop because it never sees a valid drop target at the
|
||||
// document level. This is harmless on Windows/macOS where DnD already
|
||||
// works correctly.
|
||||
useEffect(() => {
|
||||
const allow = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
// Prevent the webview from navigating when something (e.g. a file
|
||||
// from the OS file manager) is dropped on the document body.
|
||||
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
|
||||
|
||||
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
|
||||
const blockSelectAll = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
const target = e.target as HTMLElement;
|
||||
// Allow Ctrl+A inside actual text inputs and textareas
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
|
||||
const blockSelectStart = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
if ((target as HTMLElement).closest('[data-selectable]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('dragover', allow);
|
||||
document.addEventListener('dragenter', allow);
|
||||
document.addEventListener('drop', blockDrop);
|
||||
document.addEventListener('keydown', blockSelectAll, true);
|
||||
document.addEventListener('selectstart', blockSelectStart);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', allow);
|
||||
document.removeEventListener('dragenter', allow);
|
||||
document.removeEventListener('drop', blockDrop);
|
||||
document.removeEventListener('keydown', blockSelectAll, true);
|
||||
document.removeEventListener('selectstart', blockSelectStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="queue-toggle-btn"
|
||||
onClick={toggleQueue}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="content-body" style={{ padding: 0 }}>
|
||||
{connStatus === 'disconnected' && hasOfflineContent && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && !hasOfflineContent && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
isChecking={connRetrying}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
@@ -136,12 +269,19 @@ function AppShell() {
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/now-playing" element={<NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -159,70 +299,206 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tray / media key event handler
|
||||
// Media key + tray event handler
|
||||
function TauriEventBridge() {
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const { minimizeToTray } = useAuthStore();
|
||||
|
||||
// Spacebar → play/pause, F11 → window fullscreen
|
||||
// Sync tray-icon visibility with the user's stored setting.
|
||||
// Runs once on mount (initial sync) and again whenever the setting changes.
|
||||
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
||||
useEffect(() => {
|
||||
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
|
||||
}, [showTrayIcon]);
|
||||
|
||||
// Configurable keybindings
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code === 'F11') {
|
||||
e.preventDefault();
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
// Global shortcuts use modifier combos — skip in-app bindings for those
|
||||
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const { bindings } = useKeybindingsStore.getState();
|
||||
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
|
||||
|
||||
const action = (Object.entries(bindings) as [string, string | null][])
|
||||
.find(([, code]) => code === e.code)?.[0];
|
||||
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
|
||||
switch (action) {
|
||||
case 'play-pause': togglePlay(); break;
|
||||
case 'next': next(); break;
|
||||
case 'prev': previous(); break;
|
||||
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
|
||||
break;
|
||||
}
|
||||
case 'seek-backward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.max(0, s.currentTime - 10));
|
||||
break;
|
||||
}
|
||||
case 'toggle-queue': toggleQueue(); break;
|
||||
case 'fullscreen-player': toggleFullscreen(); break;
|
||||
case 'native-fullscreen': {
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [togglePlay]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const unlisten: Array<() => void> = [];
|
||||
|
||||
listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('media:next', () => next()).then(u => unlisten.push(u));
|
||||
listen('media:prev', () => previous()).then(u => unlisten.push(u));
|
||||
listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('tray:next', () => next()).then(u => unlisten.push(u));
|
||||
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
const win = getCurrentWindow();
|
||||
win.onCloseRequested(async (event) => {
|
||||
if (minimizeToTray) {
|
||||
event.preventDefault();
|
||||
await win.hide();
|
||||
} else {
|
||||
// If not minimizing to tray, we want to exit the app completely
|
||||
await invoke('exit_app');
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
for (const [event, handler] of handlers) {
|
||||
const u = await listen(event, handler);
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
}).then(u => unlisten.push(u));
|
||||
|
||||
return () => unlisten.forEach(u => u());
|
||||
}, [togglePlay, next, previous, minimizeToTray]);
|
||||
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
|
||||
{
|
||||
const u = await listen<number>('media:seek-relative', e => {
|
||||
const s = usePlayerStore.getState();
|
||||
const dur = s.currentTrack?.duration;
|
||||
if (!dur) return;
|
||||
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
{
|
||||
const u = await listen<number>('media:seek-absolute', e => {
|
||||
const s = usePlayerStore.getState();
|
||||
const dur = s.currentTrack?.duration;
|
||||
if (!dur) return;
|
||||
s.seek(e.payload / dur);
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
const u = await listen('window:close-requested', async () => {
|
||||
if (useAuthStore.getState().minimizeToTray) {
|
||||
await getCurrentWindow().hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
}
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
};
|
||||
|
||||
setup();
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const font = useFontStore(s => s.font);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useGlobalShortcutsStore.getState().registerAll();
|
||||
}, []);
|
||||
|
||||
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || !e.altKey || e.code !== 'KeyN') return;
|
||||
e.preventDefault();
|
||||
setExportPickerOpen(true);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const handleExport = async (since: number) => {
|
||||
setExportPickerOpen(false);
|
||||
try {
|
||||
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
|
||||
const result = await exportNewAlbumsImage(since);
|
||||
if (result) {
|
||||
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
|
||||
showToast(`📸 ${result.count} Alben exportiert${files}`);
|
||||
} else {
|
||||
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
|
||||
console.error('[easter egg] export failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
|
||||
const onScroll = (e: Event) => {
|
||||
const el = e.target as HTMLElement;
|
||||
el.classList.add('is-scrolling');
|
||||
const existing = timers.get(el);
|
||||
if (existing !== undefined) clearTimeout(existing);
|
||||
timers.set(el, setTimeout(() => {
|
||||
el.classList.remove('is-scrolling');
|
||||
timers.delete(el);
|
||||
}, 800));
|
||||
};
|
||||
document.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('scroll', onScroll, true);
|
||||
timers.forEach(t => clearTimeout(t));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
@@ -232,11 +508,14 @@ export default function App() {
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const API_KEY = '9917fb39049225a13bec225ad6d49054';
|
||||
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
|
||||
|
||||
export function lastfmIsConfigured(): boolean {
|
||||
return Boolean(API_KEY && API_SECRET);
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (typeof e === 'string') return e;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
}
|
||||
|
||||
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
|
||||
const entries = Object.entries(params) as [string, string][];
|
||||
try {
|
||||
const result = await invoke('lastfm_request', {
|
||||
params: entries,
|
||||
sign,
|
||||
get,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
// Clear session error on any successful authenticated call
|
||||
if (sign) useAuthStore.getState().setLastfmSessionError(false);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Last.fm error codes 4, 9, 14 = auth/session invalid
|
||||
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
|
||||
useAuthStore.getState().setLastfmSessionError(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetToken(): Promise<string> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getToken' }, false, true);
|
||||
return data.token as string;
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export function lastfmAuthUrl(token: string): string {
|
||||
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
|
||||
}
|
||||
|
||||
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getSession', token }, true, false);
|
||||
return { key: data.session.key as string, name: data.session.name as string };
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
|
||||
try {
|
||||
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
|
||||
const artists = data?.similarartists?.artist;
|
||||
if (!artists) return [];
|
||||
const arr = Array.isArray(artists) ? artists : [artists];
|
||||
return arr.map((a: any) => a.name as string);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetAllLovedTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<Array<{ title: string; artist: string }>> {
|
||||
const results: Array<{ title: string; artist: string }> = [];
|
||||
let page = 1;
|
||||
const limit = 200;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const data = await call({
|
||||
method: 'user.getLovedTracks',
|
||||
user: username,
|
||||
sk: sessionKey,
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
}, false, true);
|
||||
|
||||
const tracks = data?.lovedtracks?.track;
|
||||
if (!tracks) break;
|
||||
const arr = Array.isArray(tracks) ? tracks : [tracks];
|
||||
for (const t of arr) {
|
||||
results.push({ title: t.name, artist: t.artist?.name ?? '' });
|
||||
}
|
||||
|
||||
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
|
||||
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
|
||||
page++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function lastfmGetTrackLoved(
|
||||
title: string,
|
||||
artist: string,
|
||||
sessionKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
|
||||
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUpdateNowPlaying(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.updateNowPlaying',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmLoveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUnloveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmUserInfo {
|
||||
playcount: number;
|
||||
registeredAt: number; // unix timestamp
|
||||
}
|
||||
|
||||
export async function lastfmGetUserInfo(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<LastfmUserInfo | null> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
|
||||
const u = data?.user;
|
||||
if (!u) return null;
|
||||
return {
|
||||
playcount: Number(u.playcount),
|
||||
registeredAt: Number(u.registered?.unixtime ?? 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmRecentTrack {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
timestamp: number | null; // null = currently playing
|
||||
nowPlaying: boolean;
|
||||
}
|
||||
|
||||
export async function lastfmGetRecentTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
limit = 20,
|
||||
): Promise<LastfmRecentTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
|
||||
const items = data?.recenttracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({
|
||||
name: t.name,
|
||||
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
|
||||
album: t.album?.['#text'] ?? '',
|
||||
timestamp: t.date?.uts ? Number(t.date.uts) : null,
|
||||
nowPlaying: t['@attr']?.nowplaying === 'true',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
|
||||
|
||||
export interface LastfmTopArtist {
|
||||
name: string;
|
||||
playcount: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopAlbum {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopTrack {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export async function lastfmGetTopArtists(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopArtist[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topartists?.artist;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopAlbums(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopAlbum[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topalbums?.album;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.toptracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmScrobble(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
timestamp: number,
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.scrobble',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
timestamp: String(Math.floor(timestamp / 1000)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface LrclibLyrics {
|
||||
syncedLyrics: string | null;
|
||||
plainLyrics: string | null;
|
||||
}
|
||||
|
||||
export interface LrcLine {
|
||||
time: number; // seconds
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function fetchLyrics(
|
||||
artist: string,
|
||||
title: string,
|
||||
album: string,
|
||||
duration: number,
|
||||
): Promise<LrclibLyrics | null> {
|
||||
const params = new URLSearchParams({
|
||||
artist_name: artist,
|
||||
track_name: title,
|
||||
album_name: album,
|
||||
duration: Math.round(duration).toString(),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://lrclib.net/api/get?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
syncedLyrics: data.syncedLyrics ?? null,
|
||||
plainLyrics: data.plainLyrics ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
const text = match[3].trim();
|
||||
lines.push({ time: mins * 60 + secs, text });
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
@@ -13,7 +15,7 @@ function secureRandomSalt(): string {
|
||||
function getAuthParams(username: string, password: string) {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
@@ -51,6 +53,7 @@ export interface SubsonicAlbum {
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -72,8 +75,34 @@ export interface SubsonicSong {
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InternetRadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
homepageUrl?: string;
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface RadioBrowserStation {
|
||||
stationuuid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
@@ -85,6 +114,8 @@ export interface SubsonicPlaylist {
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
comment?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
@@ -115,6 +146,7 @@ export interface SubsonicArtistInfo {
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
@@ -156,15 +188,26 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now() };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
@@ -207,8 +250,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
|
||||
}
|
||||
|
||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
|
||||
return data.genres?.genre ?? [];
|
||||
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
||||
const raw = data.genres?.genre;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'byGenre', genre, size, offset, _t: Date.now(),
|
||||
});
|
||||
const raw = data.albumList2?.album;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
@@ -350,12 +404,70 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<void> {
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
|
||||
const params: Record<string, unknown> = { name };
|
||||
if (songIds && songIds.length > 0) {
|
||||
params.songId = songIds;
|
||||
}
|
||||
await api('createPlaylist.view', params);
|
||||
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
|
||||
if (songIds.length > 0) {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
} else if (prevCount > 0) {
|
||||
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
|
||||
// Use updatePlaylist.view with explicit index removal to clear the list instead.
|
||||
await api('updatePlaylist.view', {
|
||||
playlistId: id,
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlaylistMeta(
|
||||
id: string,
|
||||
name: string,
|
||||
comment: string,
|
||||
isPublic: boolean,
|
||||
): Promise<void> {
|
||||
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
|
||||
}
|
||||
|
||||
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
await invoke('upload_playlist_cover', {
|
||||
serverUrl: baseUrl,
|
||||
playlistId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadArtistImage(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
await invoke('upload_artist_image', {
|
||||
serverUrl: baseUrl,
|
||||
artistId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
@@ -391,3 +503,106 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Internet Radio ───────────────────────────────────────────
|
||||
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
|
||||
try {
|
||||
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
|
||||
'getInternetRadioStations.view'
|
||||
);
|
||||
return data.internetRadioStations?.internetRadioStation ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createInternetRadioStation(
|
||||
name: string, streamUrl: string, homepageUrl?: string
|
||||
): Promise<void> {
|
||||
const params: Record<string, unknown> = { name, streamUrl };
|
||||
if (homepageUrl) params.homepageUrl = homepageUrl;
|
||||
await api('createInternetRadioStation.view', params);
|
||||
}
|
||||
|
||||
export async function updateInternetRadioStation(
|
||||
id: string, name: string, streamUrl: string, homepageUrl?: string
|
||||
): Promise<void> {
|
||||
const params: Record<string, unknown> = { id, name, streamUrl };
|
||||
if (homepageUrl) params.homepageUrl = homepageUrl;
|
||||
await api('updateInternetRadioStation.view', params);
|
||||
}
|
||||
|
||||
export async function deleteInternetRadioStation(id: string): Promise<void> {
|
||||
await api('deleteInternetRadioStation.view', { id });
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRadioCoverArt(id: string): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
await invoke('delete_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
|
||||
return raw.map(s => ({
|
||||
stationuuid: s.stationuuid ?? '',
|
||||
name: s.name ?? '',
|
||||
url: s.url ?? '',
|
||||
favicon: s.favicon ?? '',
|
||||
tags: s.tags ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export const RADIO_PAGE_SIZE = 25;
|
||||
|
||||
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
|
||||
return invoke<[number[], string]>('fetch_url_bytes', { url });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Play, HardDriveDownload } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
}
|
||||
|
||||
export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
function AlbumCard({ album }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -26,14 +37,20 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
}));
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
@@ -47,21 +64,32 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{isOffline && (
|
||||
<div className="album-card-offline-badge" aria-label="Offline available">
|
||||
<HardDriveDownload size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
|
||||
aria-label={`Details zu ${album.name}`}
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
Details
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
|
||||
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p
|
||||
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||
>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AlbumCard);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (
|
||||
name.startsWith('on') ||
|
||||
(name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
|
||||
(name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
coverArt?: string;
|
||||
recordLabel?: string;
|
||||
}
|
||||
|
||||
interface AlbumHeaderProps {
|
||||
info: AlbumInfo;
|
||||
songs: SubsonicSong[];
|
||||
coverUrl: string;
|
||||
coverKey: string;
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
onToggleStar: () => void;
|
||||
onDownload: () => void;
|
||||
onCacheOffline: () => void;
|
||||
onRemoveOffline: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
info,
|
||||
songs,
|
||||
coverUrl,
|
||||
coverKey,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
offlineStatus,
|
||||
offlineProgress,
|
||||
bio,
|
||||
bioOpen,
|
||||
onToggleStar,
|
||||
onDownload,
|
||||
onCacheOffline,
|
||||
onRemoveOffline,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(info.coverArt, 2000)}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{formatLabel && <span>· {formatLabel}</span>}
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span className="album-info-dot">·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
// rest → persistent px values from useTracklistColumns hook
|
||||
|
||||
const COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
// Columns where cell content should be centred (both header and rows)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
ratings: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
}
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
ratings,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state (resize, visibility, picker) via shared hook ────────────
|
||||
const {
|
||||
colWidths, colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = CENTERED_COLS.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// num header: checkbox + # label, mirrors row-cell layout exactly
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
|
||||
// direction=1: drag right → this column grows, title (1fr) shrinks
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Row cell renderer ─────────────────────────────────────────────────────
|
||||
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
|
||||
switch (key) {
|
||||
case 'num':
|
||||
return (
|
||||
<div
|
||||
key="num"
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
{currentTrack?.id === song.id && isPlaying && (
|
||||
<span className="track-num-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
)}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{song.track ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title':
|
||||
return (
|
||||
<div key="title" className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist':
|
||||
return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>
|
||||
{song.artist}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'favorite':
|
||||
return (
|
||||
<div key="favorite" className="track-star-cell">
|
||||
<button
|
||||
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating':
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
case 'duration':
|
||||
return (
|
||||
<div key="duration" className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
);
|
||||
case 'format':
|
||||
return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'genre':
|
||||
return (
|
||||
<div key="genre" className="track-genre">
|
||||
{song.genre ?? '—'}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
{/* Column visibility picker */}
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">
|
||||
{isOn && <Check size={13} />}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tracks ── */}
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, Download, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null; error?: string }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<State>({ phase: 'idle' });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Try Tauri native updater first (macOS + Windows)
|
||||
const update = await check();
|
||||
if (cancelled) return;
|
||||
if (update) {
|
||||
setState({ phase: 'available', version: update.version, update });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Tauri updater unavailable or network error — fall through to GitHub check
|
||||
}
|
||||
// Fallback: GitHub API check (Linux / offline Tauri updater)
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
|
||||
}
|
||||
} catch {
|
||||
// No update check possible — stay idle
|
||||
}
|
||||
}, 3000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (state.phase !== 'available' || !state.update) return;
|
||||
const update = state.update;
|
||||
const savedVersion = state.version;
|
||||
let total = 0;
|
||||
let downloaded = 0;
|
||||
setState({ phase: 'downloading', pct: 0 });
|
||||
try {
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
|
||||
} else if (event.event === 'Finished') {
|
||||
setState({ phase: 'installing' });
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Update failed:', msg);
|
||||
// Surface the error so the user (and developer) can see what went wrong
|
||||
setState({ phase: 'available', version: savedVersion, update, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
open('https://github.com/Psychotoxical/psysonic/releases/latest');
|
||||
};
|
||||
|
||||
const version = state.phase === 'available' ? state.version : '';
|
||||
const canInstall = state.phase === 'available' && state.update !== null;
|
||||
const isLinuxFallback = state.phase === 'available' && state.update === null;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
|
||||
|
||||
{state.phase === 'downloading' && (
|
||||
<div className="app-updater-progress-wrap">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{state.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'installing' && (
|
||||
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{state.error && (
|
||||
<div className="app-updater-error">{state.error}</div>
|
||||
)}
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<button className="app-updater-btn-secondary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLinuxFallback && (
|
||||
<button className="app-updater-btn-primary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -10,18 +11,20 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
// memoized to prevent a new URL on every parent re-render causing refetch loops.
|
||||
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
@@ -31,14 +34,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-name" data-tooltip={artist.name}>
|
||||
{artist.name}
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<div className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
|
||||
import ArtistCardLocal from './ArtistCardLocal';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
artists: SubsonicArtist[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
|
||||
const { t } = useTranslation();
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
|
||||
// Auto-load trigger
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLoadMore = async () => {
|
||||
if (!onLoadMore || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
await onLoadMore();
|
||||
setLoadingMore(false);
|
||||
loadingRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,39 +39,23 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="artist-row-section">
|
||||
<div className="artist-row-header">
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="artist-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<div className="album-row-nav">
|
||||
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && moreLink && (
|
||||
{moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<ArrowRight size={24} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -6,16 +6,58 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
/**
|
||||
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
|
||||
* blob is still resolving — useful for <img> tags so the browser starts
|
||||
* loading immediately. Pass false for CSS background-image consumers that
|
||||
* should only see a stable blob URL (prevents a double crossfade).
|
||||
*/
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
const controller = new AbortController();
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
||||
if (!controller.signal.aborted) setResolved(url);
|
||||
});
|
||||
return () => { controller.abort(); };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
return <img src={resolvedSrc} {...props} />;
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
const [inView, setInView] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
// URL upgrades within the same image — avoids the end-of-load flash.
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
}, [cacheKey]);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { version } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ChangelogModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [dontShow, setDontShow] = useState(false);
|
||||
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
|
||||
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
|
||||
|
||||
const currentVersionData = useMemo(() => {
|
||||
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
|
||||
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
|
||||
if (!block) return null;
|
||||
const lines = block.split('\n');
|
||||
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShow) setShowChangelogOnUpdate(false);
|
||||
setLastSeenChangelogVersion(version);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!currentVersionData) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
|
||||
<div
|
||||
className="eq-popup"
|
||||
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
|
||||
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
|
||||
{t('changelog.modalTitle')} — v{version}
|
||||
</span>
|
||||
{currentVersionData.date && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||||
{currentVersionData.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
|
||||
{currentVersionData.body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### '))
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
if (line.startsWith('#### '))
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
if (line.startsWith('- '))
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
flexShrink: 0,
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
|
||||
{t('changelog.dontShowAgain')}
|
||||
</label>
|
||||
<button className="btn btn-primary" onClick={handleClose}>
|
||||
{t('changelog.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
isLan: boolean;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip =
|
||||
status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server">{serverName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -17,19 +21,164 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
// ── Add-to-Playlist submenu ───────────────────────────────────────
|
||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [flipLeft, setFlipLeft] = useState(false);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const recentIds = usePlaylistStore((s) => s.recentIds);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists().then((all) => {
|
||||
const sorted = [...all].sort((a, b) => {
|
||||
const ai = recentIds.indexOf(a.id);
|
||||
const bi = recentIds.indexOf(b.id);
|
||||
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
setPlaylists(sorted);
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Flip submenu left if it would overflow the right edge of the viewport
|
||||
useLayoutEffect(() => {
|
||||
if (subRef.current) {
|
||||
const rect = subRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) newNameRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||
setAdding(pl.id);
|
||||
try {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const existingIds = new Set(songs.map((s) => s.id));
|
||||
const newIds = songIds.filter((id) => !existingIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
||||
}
|
||||
touchPlaylist(pl.id);
|
||||
} catch {}
|
||||
setAdding(null);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) touchPlaylist(pl.id);
|
||||
} catch {}
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = dropDown
|
||||
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
|
||||
: flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
{/* New Playlist row */}
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
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" />
|
||||
|
||||
{playlists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||
)}
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAdd(pl)}
|
||||
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Same as AddToPlaylistSubmenu but resolves album songs first
|
||||
function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) {
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbum(albumId).then((data) => {
|
||||
setResolvedIds(data.songs.map((s) => s.id));
|
||||
}).catch(() => setResolvedIds([]));
|
||||
}, [albumId]);
|
||||
|
||||
if (resolvedIds === null) {
|
||||
return (
|
||||
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolvedIds.length === 0) return null;
|
||||
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||
}
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Adjusted coordinates to keep menu on screen
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false);
|
||||
const [playlistSongIds, setPlaylistSongIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen) {
|
||||
setCoords({ x: contextMenu.x, y: contextMenu.y });
|
||||
setPlaylistSubmenuOpen(false);
|
||||
setPlaylistSongIds([]);
|
||||
}
|
||||
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
|
||||
|
||||
@@ -50,49 +199,96 @@ export default function ContextMenu() {
|
||||
|
||||
const { type, item, queueIndex } = contextMenu;
|
||||
|
||||
const isStarred = (id: string, itemStarred?: string) =>
|
||||
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
|
||||
|
||||
const handleAction = async (action: () => void | Promise<void>) => {
|
||||
closeContextMenu();
|
||||
await action();
|
||||
};
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string) => {
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === seedTrack.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
// Already playing this track — don't restart
|
||||
} else {
|
||||
playTrack(seedTrack, [seedTrack]);
|
||||
}
|
||||
// Load radio queue in background — enqueueRadio replaces any pending radio
|
||||
// tracks so clicking "Start Radio" again never stacks duplicate batches.
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedTrack.id)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
} else {
|
||||
// Artist radio: fire both calls immediately but don't wait for the slow one.
|
||||
// getTopSongs is fast (local library) — start playback as soon as it resolves.
|
||||
// getSimilarSongs2 is slow (Last.fm) — enrich the queue in the background.
|
||||
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
|
||||
try {
|
||||
const top = await getTopSongs(artistName);
|
||||
const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (topTracks.length === 0) {
|
||||
// No local top songs — fall back to waiting for similar tracks
|
||||
const similar = await similarPromise;
|
||||
const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (fallback.length === 0) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(fallback, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(fallback[0], fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Start playback immediately from top songs
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(topTracks, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(topTracks[0], topTracks);
|
||||
}
|
||||
// Enrich with similar tracks in the background
|
||||
similarPromise.then(similar => {
|
||||
const similarTracks = similar
|
||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
.filter(t => !topTracks.some(top => top.id === t.id));
|
||||
if (similarTracks.length === 0) return;
|
||||
// Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them
|
||||
// together with the new similar tracks rather than losing them.
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
try {
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
@@ -132,25 +328,61 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const tracks = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {t('contextMenu.favorite')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(song.id, song.starred);
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -167,12 +399,28 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
|
||||
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -185,8 +433,13 @@ export default function ContextMenu() {
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
|
||||
<Star size={14} /> {t('contextMenu.favoriteArtist')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -204,10 +457,53 @@ export default function ContextMenu() {
|
||||
})}>
|
||||
{t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(song.id, song.starred);
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CoverLightbox({ src, alt, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
|
||||
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
||||
<img
|
||||
className="cover-lightbox-img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
group?: string; // group label — shown as non-selectable header when it changes
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropStyle, setDropStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const maxH = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
||||
|
||||
setDropStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!listRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`custom-select-trigger ${className}`}
|
||||
style={style}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="custom-select-label">{selected?.label ?? value}</span>
|
||||
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
className="custom-select-dropdown"
|
||||
style={dropStyle}
|
||||
role="listbox"
|
||||
>
|
||||
{options.reduce<React.ReactNode[]>((acc, opt, i) => {
|
||||
const prevGroup = i > 0 ? options[i - 1].group : undefined;
|
||||
if (opt.group && opt.group !== prevGroup) {
|
||||
acc.push(
|
||||
<div key={`group-${opt.group}`} className="custom-select-group-label">
|
||||
{opt.group}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
acc.push(
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`custom-select-option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}`}
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={() => { if (!opt.disabled) { onChange(opt.value); setOpen(false); } }}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
);
|
||||
return acc;
|
||||
}, [])}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function DownloadFolderModal() {
|
||||
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
|
||||
if (selected && typeof selected === 'string') setFolder(selected);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
|
||||
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 0 12px' }}>
|
||||
<div className="download-folder-pick-row">
|
||||
<span className="download-folder-path">
|
||||
{folder || t('common.noFolderSelected')}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={15} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="download-remember-row">
|
||||
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
|
||||
<span>{t('common.rememberDownloadFolder')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="download-modal-actions">
|
||||
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => confirm(setDownloadFolder)}
|
||||
disabled={!folder}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import CustomSelect from './CustomSelect';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
// ─── Frequency response canvas ────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const EQ_Q = 1.41;
|
||||
|
||||
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
|
||||
if (Math.abs(gainDb) < 0.01) return 0;
|
||||
const w0 = (2 * Math.PI * centerHz) / sampleRate;
|
||||
const A = Math.pow(10, gainDb / 40);
|
||||
const alpha = Math.sin(w0) / (2 * EQ_Q);
|
||||
const b0 = 1 + alpha * A;
|
||||
const b1 = -2 * Math.cos(w0);
|
||||
const b2 = 1 - alpha * A;
|
||||
const a0 = 1 + alpha / A;
|
||||
const a1 = -2 * Math.cos(w0);
|
||||
const a2 = 1 - alpha / A;
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const cosW = Math.cos(w), sinW = Math.sin(w);
|
||||
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
|
||||
const numRe = b0 + b1 * cosW + b2 * cos2W;
|
||||
const numIm = - b1 * sinW - b2 * sin2W;
|
||||
const denRe = a0 + a1 * cosW + a2 * cos2W;
|
||||
const denIm = - a1 * sinW - a2 * sin2W;
|
||||
const numMag2 = numRe * numRe + numIm * numIm;
|
||||
const denMag2 = denRe * denRe + denIm * denIm;
|
||||
return 10 * Math.log10(numMag2 / denMag2);
|
||||
}
|
||||
|
||||
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.offsetWidth;
|
||||
const H = canvas.offsetHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const fMin = 20, fMax = 20000;
|
||||
const dbMin = -13, dbMax = 13;
|
||||
const padL = 36, padR = 8, padT = 8, padB = 1;
|
||||
const innerW = W - padL - padR;
|
||||
const innerH = H - padT - padB;
|
||||
|
||||
const freqToX = (f: number) =>
|
||||
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
|
||||
const dbToY = (db: number) =>
|
||||
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid: dB lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
[-12, -6, 0, 6, 12].forEach(db => {
|
||||
const y = dbToY(db);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(W - padR, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
|
||||
});
|
||||
|
||||
// Grid: frequency lines
|
||||
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
|
||||
const x = freqToX(f);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT);
|
||||
ctx.lineTo(x, H - padB);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Zero line (brighter)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, dbToY(0));
|
||||
ctx.lineTo(W - padR, dbToY(0));
|
||||
ctx.stroke();
|
||||
|
||||
// Frequency response curve
|
||||
const points: [number, number][] = [];
|
||||
const steps = innerW * 2;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = fMin * Math.pow(fMax / fMin, i / steps);
|
||||
let totalDb = 0;
|
||||
for (let band = 0; band < 10; band++) {
|
||||
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
|
||||
}
|
||||
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
|
||||
points.push([freqToX(f), dbToY(totalDb)]);
|
||||
}
|
||||
|
||||
// Fill under curve
|
||||
const grad = ctx.createLinearGradient(0, padT, 0, H);
|
||||
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
|
||||
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], dbToY(0));
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.lineTo(points[points.length - 1][0], dbToY(0));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Curve line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
points.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ─── Custom vertical fader (no native range input) ────────────────────────────
|
||||
|
||||
const GAIN_MIN = -12, GAIN_MAX = 12;
|
||||
|
||||
interface FaderProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
|
||||
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
|
||||
|
||||
const updateFromY = useCallback((clientY: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
dragging.current = true;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging.current || disabled) return;
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => { dragging.current = false; };
|
||||
|
||||
const thumbPct = gainToPct(value) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="eq-fader-custom"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="eq-track-line" />
|
||||
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface AutoEqVariant { form: string; rig: string | null; source: string; }
|
||||
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
|
||||
|
||||
|
||||
/** Parses AutoEQ FixedBandEQ.txt format.
|
||||
* Expected lines:
|
||||
* Preamp: -5.5 dB
|
||||
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
|
||||
* ...
|
||||
* Returns all 10 band gains as exact floats and the preamp value.
|
||||
*/
|
||||
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
|
||||
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
|
||||
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
|
||||
|
||||
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
|
||||
allFilters.slice(0, 10).forEach((m, i) => {
|
||||
gains[i] = parseFloat(m[1]);
|
||||
});
|
||||
|
||||
return { gains, preamp };
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const preGain = useEqStore(s => s.preGain);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// AutoEQ state
|
||||
const [autoEqOpen, setAutoEqOpen] = useState(false);
|
||||
const [autoEqQuery, setAutoEqQuery] = useState('');
|
||||
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
|
||||
const [autoEqLoading, setAutoEqLoading] = useState(false);
|
||||
const [autoEqError, setAutoEqError] = useState<string | null>(null);
|
||||
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
|
||||
const [entriesLoading, setEntriesLoading] = useState(false);
|
||||
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(redraw);
|
||||
if (canvasRef.current) ro.observe(canvasRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// AutoEQ: load entries index lazily when section opens, then filter client-side
|
||||
async function ensureEntries() {
|
||||
if (entriesCacheRef.current) return;
|
||||
setEntriesLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const json = await invoke<string>('autoeq_entries');
|
||||
entriesCacheRef.current = JSON.parse(json);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
|
||||
} finally {
|
||||
setEntriesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = autoEqQuery.trim().toLowerCase();
|
||||
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
|
||||
const flat: AutoEqResult[] = [];
|
||||
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
|
||||
if (!name.toLowerCase().includes(q)) continue;
|
||||
for (const v of variants) {
|
||||
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
setAutoEqResults(flat);
|
||||
// entriesLoading in deps: re-runs after entries finish loading so a query typed
|
||||
// during loading produces results immediately without needing a re-type.
|
||||
}, [autoEqQuery, entriesLoading]);
|
||||
|
||||
async function applyAutoEqResult(result: AutoEqResult) {
|
||||
setAutoEqLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const text = await invoke<string>('autoeq_fetch_profile', {
|
||||
name: result.name,
|
||||
source: result.source,
|
||||
rig: result.rig ?? null,
|
||||
form: result.form,
|
||||
});
|
||||
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
|
||||
const { gains: newGains, preamp } = parseFixedBandEqString(text);
|
||||
applyAutoEq(result.name, newGains, preamp);
|
||||
setAutoEqApplied(result.name);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setTimeout(() => setAutoEqApplied(null), 3000);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
|
||||
} finally {
|
||||
setAutoEqLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
|
||||
const handleSave = () => {
|
||||
const name = saveName.trim();
|
||||
if (!name) return;
|
||||
saveCustomPreset(name);
|
||||
setSaveName('');
|
||||
setShowSave(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="eq-wrap">
|
||||
{/* Controls bar */}
|
||||
<div className="eq-controls-bar">
|
||||
<label className="eq-toggle-label">
|
||||
<span>{t('settings.eqEnabled')}</span>
|
||||
<label className="toggle-switch" style={{ marginLeft: 8 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<div className="eq-preset-row">
|
||||
<CustomSelect
|
||||
className="eq-preset-select"
|
||||
value={selectValue}
|
||||
onChange={v => applyPreset(v)}
|
||||
options={[
|
||||
...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []),
|
||||
...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })),
|
||||
...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })),
|
||||
]}
|
||||
/>
|
||||
|
||||
{isCustomSaved && (
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} data-tooltip={t('settings.eqDeletePreset')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} data-tooltip={t('settings.eqResetBands')}>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} data-tooltip={t('settings.eqSavePreset')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSave && (
|
||||
<div className="eq-save-row">
|
||||
<input
|
||||
type="text" className="input" placeholder={t('settings.eqPresetName')}
|
||||
value={saveName} onChange={e => setSaveName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!saveName.trim()}>{t('common.save')}</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setShowSave(false); setSaveName(''); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AutoEQ section */}
|
||||
<div className="eq-autoeq-section">
|
||||
<button
|
||||
className="eq-autoeq-toggle"
|
||||
onClick={() => {
|
||||
const opening = !autoEqOpen;
|
||||
setAutoEqOpen(opening);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setAutoEqError(null);
|
||||
if (opening) ensureEntries();
|
||||
}}
|
||||
>
|
||||
<Search size={13} />
|
||||
<span>{t('settings.eqAutoEqTitle')}</span>
|
||||
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
|
||||
{autoEqOpen && (
|
||||
<div className="eq-autoeq-body">
|
||||
<div className="eq-autoeq-search-row">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('settings.eqAutoEqPlaceholder')}
|
||||
value={autoEqQuery}
|
||||
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
|
||||
autoFocus
|
||||
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
{autoEqQuery && (
|
||||
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(entriesLoading || autoEqLoading) && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
|
||||
)}
|
||||
{autoEqError && (
|
||||
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
|
||||
)}
|
||||
{autoEqApplied && (
|
||||
<div className="eq-autoeq-status eq-autoeq-applied">✓ {autoEqApplied}</div>
|
||||
)}
|
||||
|
||||
{autoEqResults.length > 0 && (
|
||||
<div className="eq-autoeq-results">
|
||||
{autoEqResults.map((r, i) => (
|
||||
<button
|
||||
key={`${r.name}|${r.source}|${i}`}
|
||||
className="eq-autoeq-result-btn"
|
||||
onClick={() => applyAutoEqResult(r)}
|
||||
>
|
||||
<span>{r.name}</span>
|
||||
<span className="eq-autoeq-result-source">{r.source}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EQ panel */}
|
||||
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
|
||||
{/* Frequency response */}
|
||||
<canvas ref={canvasRef} className="eq-canvas" />
|
||||
|
||||
{/* Fader area */}
|
||||
<div className="eq-faders">
|
||||
{/* dB scale */}
|
||||
<div className="eq-db-scale">
|
||||
{[12, 6, 0, -6, -12].map(db => (
|
||||
<span key={db} className="eq-db-tick">
|
||||
{db > 0 ? `+${db}` : db}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bands */}
|
||||
{EQ_BANDS.map((band, i) => (
|
||||
<div key={band.freq} className="eq-band">
|
||||
<span className="eq-gain-val">
|
||||
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
|
||||
</span>
|
||||
<div className="eq-fader-track">
|
||||
<div className="eq-zero-mark" />
|
||||
<VerticalFader
|
||||
value={gains[i]}
|
||||
disabled={!enabled}
|
||||
onChange={v => setBandGain(i, v)}
|
||||
/>
|
||||
</div>
|
||||
<span className="eq-freq-label">{band.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pre-gain row */}
|
||||
<div className="eq-pregain-row">
|
||||
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="eq-pregain-slider"
|
||||
min={-30} max={6} step={0.1}
|
||||
value={preGain}
|
||||
disabled={!enabled}
|
||||
onChange={e => setPreGain(parseFloat(e.target.value))}
|
||||
/>
|
||||
<span className="eq-pregain-val">
|
||||
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
|
||||
</span>
|
||||
{preGain !== 0 && (
|
||||
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Download } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
onConfirm: (since: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExportPickerModal({ onConfirm, onClose }: Props) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [date, setDate] = useState(today);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const since = new Date(date + 'T00:00:00').getTime();
|
||||
onConfirm(since);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99998,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '14px',
|
||||
padding: '28px 32px',
|
||||
width: '340px',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Alben exportieren
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: '4px', display: 'flex' }}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '0 0 16px', fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
Alle Alben exportieren, die seit diesem Datum hinzugekommen sind:
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={today}
|
||||
onChange={e => {
|
||||
setDate(e.target.value);
|
||||
e.target.blur();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
background: 'var(--bg-app)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
colorScheme: 'dark',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
|
||||
<button className="btn btn-surface" onClick={onClose} style={{ flex: 1 }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!date}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
|
||||
>
|
||||
<Download size={15} />
|
||||
Exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,45 +15,8 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function MarqueeTitle({ title }: { title: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollAmount, setScrollAmount] = useState(0);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
// Temporarily make span inline-block to get its natural width
|
||||
text.style.display = 'inline-block';
|
||||
const textWidth = text.getBoundingClientRect().width;
|
||||
text.style.display = '';
|
||||
const overflow = textWidth - container.clientWidth;
|
||||
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [title, measure]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fs-title-wrap">
|
||||
<span
|
||||
ref={textRef}
|
||||
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
|
||||
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Crossfading blurred background ───────────────────────────────────────────
|
||||
const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
// ─── Artist portrait — right half, crossfades on track change ─────────────────
|
||||
const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
@@ -61,33 +24,45 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => {
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (layers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fs-portrait-wrap" aria-hidden="true">
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
<img
|
||||
key={layer.id}
|
||||
className="fs-bg"
|
||||
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
src={layer.url}
|
||||
className="fs-portrait"
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
|
||||
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
|
||||
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
|
||||
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
@@ -102,21 +77,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
|
||||
return (
|
||||
<div className="fs-progress-wrap">
|
||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||
<div className="fs-progress-bar">
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
|
||||
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{
|
||||
'--pct': `${pct}%`,
|
||||
'--buf': `${buf}%`,
|
||||
} as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
aria-label="seek"
|
||||
/>
|
||||
</div>
|
||||
<span className="fs-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -140,20 +116,40 @@ interface FullscreenPlayerProps {
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const nextVal = !isStarred;
|
||||
setStarredOverride(currentTrack.id, nextVal);
|
||||
try {
|
||||
if (nextVal) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !nextVal);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
setArtistBgUrl('');
|
||||
@@ -166,7 +162,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
@@ -174,80 +170,105 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const metaParts = [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
|
||||
{/* Layer 1 — blurred artist image (falls back to cover art) */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 2 — drifting color orbs */}
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
<FsPortrait url={portraitUrl} />
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<div className="fs-cover-wrap">
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-cover"
|
||||
className="fs-art"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<MarqueeTitle title={currentTrack?.title ?? '—'} />
|
||||
<p className="fs-album">
|
||||
{currentTrack?.album ?? ''}
|
||||
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
|
||||
</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
|
||||
].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Artist — massive statement */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
{/* Track title — accent, light weight */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||