mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
308 Commits
v1.34.16
...
app-v1.44.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ef45b726ad | |||
| 1d569f6282 | |||
| 106baec8fe | |||
| 98644d859f | |||
| 10ca1bc051 | |||
| e3ac4500ae | |||
| 4b9806ccc0 | |||
| 2bea55bedd | |||
| 70ff025ece | |||
| 2ff7ab5697 | |||
| fbd6611049 | |||
| e15ca83bd5 | |||
| 14c66da087 | |||
| 8a38127bf7 | |||
| cf24dc0e7b | |||
| b7a842395c | |||
| ba9f5728b3 | |||
| 2a08115ba8 | |||
| 133e09ec63 | |||
| a9573625f4 | |||
| 8fe75b3d74 | |||
| 408052adc8 | |||
| 42c6b2274d | |||
| bf38288feb | |||
| 8141c5213f | |||
| bce65be287 | |||
| cfd7fb369a | |||
| 402120143e | |||
| bd2242e4f3 | |||
| 8967ca825d | |||
| d2080588b9 | |||
| 87373edb17 | |||
| cf09fd4bd3 | |||
| 8a97a35ce6 | |||
| 55a49a9fb0 | |||
| c64a1e195e | |||
| 39f4d03da5 | |||
| e0c53da94d | |||
| 9fe81ee6f6 | |||
| d67db230ad | |||
| 8f603e8de9 | |||
| 218aa00718 | |||
| 5cb233e1c9 | |||
| 2d222e2691 | |||
| faa931ff68 | |||
| 90a6f09b9f | |||
| 67a100ee3f | |||
| f92cfa183d | |||
| 1dcc1e101c | |||
| 577950dc22 | |||
| a910162cfe | |||
| c4a283b809 | |||
| 6a39c5aaa1 | |||
| 8b30d3bdfa | |||
| 8a6ff04b05 | |||
| 4217e8e6a0 | |||
| c674651d35 | |||
| 756b189bcc | |||
| d467ea1779 | |||
| ed76090a54 | |||
| 3c1611270a | |||
| 185cb8f7cd | |||
| f95391318f | |||
| 77b2a5401a | |||
| ea93f7fc4d | |||
| 0667d96085 | |||
| ab1b1dcffa | |||
| 4b60495e38 | |||
| 0d248d655e | |||
| 4a0bdfed92 | |||
| a41628bdf9 | |||
| d31291a463 | |||
| c6fc3ec844 | |||
| 53cab7654c | |||
| 9306f0af2c | |||
| 2fae1b4c0e | |||
| 453151f1fc | |||
| e009fd1823 | |||
| 86704473ed | |||
| e15c0c3d36 | |||
| 9048e9426e | |||
| e2970bcaac | |||
| 3672504aaf | |||
| f3acb795fb | |||
| b4b786972e | |||
| 2a69ed683d | |||
| 0f75dada1e | |||
| 61b53c4448 | |||
| 0404a23cc9 | |||
| 3673d826b1 | |||
| 3c0a42e298 | |||
| 807e7b4520 | |||
| e3aabd98b7 | |||
| 300ca7d1e5 | |||
| bf53016de1 | |||
| 0e941c3ee4 | |||
| 93b724fc65 | |||
| 861d4c9616 | |||
| e4cc54a1b5 | |||
| 7c379c2111 | |||
| cfb7e7f6c1 | |||
| 6ca678547b | |||
| a84be98140 | |||
| 2b1124d14a | |||
| 0d18d5dfa9 | |||
| 682fe99bba | |||
| 67d51a0975 | |||
| dd947df0b9 | |||
| 908c7ceebb | |||
| 2476c2197d | |||
| e4ef31ce47 | |||
| 114b58d128 | |||
| 8b369b3fa9 | |||
| 048d7249a4 | |||
| cecc59aead | |||
| 4d7588fdd0 | |||
| e3e2da07c0 | |||
| 7378bdf820 | |||
| abbf6fc345 | |||
| c9977a20e9 | |||
| b0c153ec48 | |||
| b4bb40802d | |||
| d912c4293b | |||
| 6370b5fa3c | |||
| f2e4c5b684 | |||
| 728b8f6315 | |||
| e6cb27bf3b | |||
| a1edec4a72 | |||
| 01e148d082 | |||
| 17bcac7155 | |||
| e6d15bf9ce | |||
| ef8d83955a | |||
| 3aeeaea74f | |||
| cf6fbe527a | |||
| 81d16183b7 | |||
| 401fed8368 | |||
| 7acf95c0a6 | |||
| da4e0189b9 | |||
| ae053e5314 | |||
| 9baf01fdc2 | |||
| 27a59f6b23 | |||
| 59bae4b545 | |||
| 3bbf628526 | |||
| 2d32ef7b2d | |||
| 9d8ee836b5 | |||
| 1a470b51b5 | |||
| 6c7f455e66 | |||
| 089774dc3e | |||
| 5326011268 | |||
| 373029884c | |||
| ce8d389070 | |||
| ea01470df4 | |||
| 99612c3850 | |||
| 67385c7cef | |||
| 23edac69ef | |||
| 7ba7d6bf25 | |||
| f8bcd57ed4 | |||
| fe7dc7af54 | |||
| 1c761682f4 | |||
| d5476b9249 | |||
| e86133738d | |||
| e721b3060d | |||
| c54aa22e6b | |||
| 861798ea7a | |||
| 73a04e4c00 | |||
| 5c87a94170 | |||
| b082f51213 | |||
| a17d4c883d | |||
| 56d44046e3 | |||
| b97b6c545b | |||
| 0ead4fbeb1 | |||
| e9e61b9a05 | |||
| ab5c8e0b48 | |||
| dff2c4a121 | |||
| c7af6a6e15 | |||
| 11dddf6290 | |||
| 2b97a7b01e | |||
| 7fe492d233 | |||
| dc82e49bd1 | |||
| 9e1256e200 | |||
| cebf0e238d | |||
| 60e0bbfa2a | |||
| 87c51e3b11 | |||
| a45943d078 | |||
| e398d68184 | |||
| 694567843f | |||
| 624ce56faf | |||
| c5dfabf739 | |||
| 2d320d8681 | |||
| db72ed9e5a | |||
| b4f31e0954 | |||
| f0e5b2542b | |||
| ae7b94f190 | |||
| e89ae18780 | |||
| 7c9a300022 | |||
| 21d00889aa | |||
| 2a496c600b | |||
| f6f76723d8 | |||
| b61c168430 | |||
| 2318f9e07a | |||
| f7a721b7b1 | |||
| c40243dfea | |||
| 0dfc3fcfe2 | |||
| aa0776e811 | |||
| 88cd09c0be | |||
| a76616b342 | |||
| 1ec27f9aff | |||
| 9da1d643f7 | |||
| 06140a490b | |||
| 76ed6eca55 | |||
| fb02b62a54 | |||
| bac0afe6ae | |||
| 3b3833007b | |||
| fa21379dbb | |||
| c61bcacd0d | |||
| f73cca669b | |||
| a1427202a9 | |||
| ae92c452b6 | |||
| 1ce199ee4b | |||
| cb86bd7602 | |||
| 77598e10e9 | |||
| e7bfe276c3 | |||
| f510223d2d | |||
| 2d3be10c8f | |||
| 459c9f688d | |||
| 6d3c50264a | |||
| 9f21edee80 | |||
| a7eb0eda72 | |||
| 9687f90b54 | |||
| ccf6e6c886 | |||
| 5aac4d4ed0 | |||
| ccff7be499 | |||
| 99fc62e505 | |||
| dc819f3a2c | |||
| 69dd374e80 | |||
| 36caf3a14c | |||
| d145a7758e | |||
| fc0b8fbcab | |||
| 5a51c2c713 | |||
| fab1dbf863 | |||
| 8a0810ffb4 | |||
| 6e34dc8160 | |||
| 2aa88d26d4 | |||
|
22fe0fff74
|
|||
| 12bebdde06 | |||
| 50c886fd6f | |||
|
65413c954e
|
|||
|
059900e85e
|
|||
| dea9d9b5a4 | |||
| d191404f2d | |||
| ffffe268ab | |||
| 81ed6db9d1 | |||
| 6878867e64 | |||
| f53f724e1c | |||
| 9bcef81b22 | |||
| 693766134b | |||
| 71fbc717f6 | |||
| d33c7042b6 | |||
| 61c17d2e24 | |||
| e4abaf8814 | |||
| c17fc0f6ac | |||
| 16491f6321 | |||
| d8244e0139 | |||
| 60da17f7cc | |||
| 8cc115cade | |||
| e07ef0ebf1 | |||
| 6456f13bde | |||
| a48159d302 | |||
| 2871db9a96 | |||
| 42ad24cce1 | |||
| eb747ba1ae | |||
| 6bd2bfc01c | |||
| b5751c2918 | |||
| 8c050ad297 | |||
| e2100aedc4 | |||
| aa69f360eb | |||
| ee932db8a9 | |||
| 461a759f0f | |||
| 193a37cf0c | |||
| 65a46bdd0f | |||
| 0afcc4ab68 | |||
| ab35ef5eb4 | |||
| 4ff4ea0df0 | |||
| cef2db92cb | |||
| 72e193cf2c | |||
| 6b3e809d12 | |||
| 2e5a34178b | |||
| 25537f2743 | |||
| 8b7bce5b85 | |||
| 89e8f43add | |||
| da38b411b0 | |||
| 4f2c313bb7 | |||
| c96eb0a805 | |||
| 66c0ecbc1f | |||
| 225609e93c | |||
| 4773f1ca8c | |||
| 129cd3ea23 | |||
| 98352bb656 | |||
| a2f880da0d | |||
| 95f714654d | |||
| e201bc630c | |||
| 36f0ef79fe | |||
| 939ed3d412 | |||
| bddfd45086 | |||
| 48c10e5619 | |||
| 216e6c957f | |||
| 425addffa0 | |||
| 43df830960 |
@@ -0,0 +1,15 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ci sentinel
|
||||
run: echo "main CI sentinel is green"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Next Channel
|
||||
|
||||
# Pushes made with the default GITHUB_TOKEN from another workflow (e.g. Promote main to next)
|
||||
# do not trigger push workflows — GitHub blocks recursive runs. Use workflow_run below.
|
||||
#
|
||||
# workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml
|
||||
# is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
# Merging auto PR "chore(nix): refresh lock …" only touches these paths — do not
|
||||
# re-run the full channel (would loop: publish → verify-nix PR → merge → push → publish).
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Promote main to next"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
publish-next:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: next
|
||||
# Always build from channel branch; do not tie checkout to workflow_dispatch UI branch.
|
||||
source_ref: next
|
||||
target_branch: next
|
||||
prerelease: true
|
||||
draft_release: true
|
||||
verify_nix: true
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,70 @@
|
||||
# Keeps `nix/upstream-sources.json` (`npmDepsHash`) in sync with `package-lock.json`.
|
||||
# Runs in CI with Nix — contributors do not need Nix locally.
|
||||
#
|
||||
# Skips fork PRs (cannot push to the contributor branch); after merge to main,
|
||||
# next, or release, the push workflow updates the hash on that branch if needed.
|
||||
name: Sync Nix npmDepsHash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, next, release]
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
pull_request:
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: nix-npm-hash-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
# Fork PRs: token cannot push to the fork — merge to main will run the push workflow instead.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
|
||||
- name: Recompute npmDepsHash from package-lock.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
TMP="$(mktemp)"
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > "$TMP"
|
||||
mv "$TMP" nix/upstream-sources.json
|
||||
|
||||
- name: Commit and push if changed
|
||||
env:
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "npmDepsHash already matches package-lock.json — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore(nix): sync npmDepsHash with package-lock.json"
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
git push origin "HEAD:${PR_HEAD_REF}"
|
||||
else
|
||||
git push origin "HEAD:${{ github.ref_name }}"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Promote main to next
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate-main:
|
||||
uses: ./.github/workflows/validate-main-green-ci.yml
|
||||
with:
|
||||
branch: main
|
||||
required_contexts: ci-ok|ci-main / ci-ok
|
||||
|
||||
promote:
|
||||
needs: validate-main
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: next
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward next to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main next
|
||||
# Reset channel branch to main snapshot, then create a fresh RC bump commit.
|
||||
git reset --hard origin/main
|
||||
- name: bump package version to next RC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags origin
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
|
||||
if [[ -z "$MAX_RC" ]]; then
|
||||
NEXT_RC=1
|
||||
else
|
||||
NEXT_RC=$((MAX_RC + 1))
|
||||
fi
|
||||
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
|
||||
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
|
||||
echo "package.json already uses $TARGET_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit RC version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version bump changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
git commit -m "chore(release): bump next channel to ${NEW_VERSION}"
|
||||
- name: push next
|
||||
run: git push --force-with-lease origin HEAD:next
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Promote next to release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: release
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward release to next
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin next release
|
||||
# Reset stable channel to next snapshot, then finalize version on top.
|
||||
git reset --hard origin/next
|
||||
- name: finalize package version for release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
|
||||
echo "package.json is already final: $FINAL_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$FINAL_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit final version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No finalization changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
git commit -m "chore(release): finalize release version ${NEW_VERSION}"
|
||||
- name: push release
|
||||
run: git push --force-with-lease origin HEAD:release
|
||||
+24
-280
@@ -1,285 +1,29 @@
|
||||
name: Release
|
||||
name: Release Channel
|
||||
|
||||
# Same GITHUB_TOKEN push limitation as Next Channel (see next.yml).
|
||||
#
|
||||
# workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches: [release]
|
||||
# Same nix-refresh loop as next.yml — ignore lock-only merges from verify-nix PRs.
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Promote next to release"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: '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: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'windows-latest'
|
||||
args: '--bundles nsis'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: '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
|
||||
- name: write Apple API key (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
|
||||
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
|
||||
- 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 }}
|
||||
# Apple signing + notarization (macOS runner only — ignored on Windows)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
|
||||
# Tauri Updater signing — produces .sig files alongside the update bundles
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
- name: upload updater signature (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
SIG=$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz.sig' | head -1)
|
||||
if [ -z "$SIG" ]; then
|
||||
echo "::error::No .sig file found under src-tauri/target/*/release/bundle/macos/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found signature: $SIG"
|
||||
if echo "$SIG" | grep -q 'aarch64-apple-darwin'; then
|
||||
DST="Psysonic_aarch64.app.tar.gz.sig"
|
||||
else
|
||||
DST="Psysonic_x64.app.tar.gz.sig"
|
||||
fi
|
||||
cp "$SIG" "$DST"
|
||||
gh release upload "app-v${VERSION}" "$DST" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools cmake
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: '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
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
# Verifies that `nix build .#psysonic` still works against the current source,
|
||||
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
|
||||
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
|
||||
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
|
||||
# without having to compile locally.
|
||||
#
|
||||
# The refreshed lock/hash files are committed back to `main` when they change.
|
||||
verify-nix:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# Full history so we can push the auto-commit back to the default branch.
|
||||
fetch-depth: 0
|
||||
# Checkout main, not the tag — we want to push lock/hash refreshes to
|
||||
# the moving branch, not the immutable tag ref.
|
||||
ref: main
|
||||
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
|
||||
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
|
||||
# server-side). The action watches the nix store during subsequent build
|
||||
# steps and uploads new paths automatically.
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
cat nix/upstream-sources.json
|
||||
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
|
||||
- name: verify nix build + push to Cachix
|
||||
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
|
||||
- name: commit + push refreshed lock and hash (if changed)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ needs.create-release.outputs.package_version }}"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin HEAD:main
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: release
|
||||
source_ref: release
|
||||
target_branch: release
|
||||
prerelease: false
|
||||
draft_release: true
|
||||
verify_nix: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
name: Reusable Channel Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Delivery channel name (release/next)"
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: "Git ref to build from"
|
||||
required: true
|
||||
type: string
|
||||
target_branch:
|
||||
description: "Branch that receives nix refresh PRs"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark GitHub release as prerelease"
|
||||
required: true
|
||||
type: boolean
|
||||
draft_release:
|
||||
description: "Create release as draft"
|
||||
required: true
|
||||
type: boolean
|
||||
verify_nix:
|
||||
description: "Run verify-nix job"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
release_tag: ${{ steps.tag.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$(node -p 'require("./package.json").version')"
|
||||
# Never publish with a missing/non-semver-ish version — empty becomes tag "app-v" on GitHub and breaks manifests.
|
||||
if [ -z "$V" ]; then
|
||||
echo "::error::package.json version is empty"
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::package.json version must start with semver X.Y.Z... (got '$V')"
|
||||
exit 1
|
||||
fi
|
||||
delim="PKGVER_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}_$(openssl rand -hex 8)"
|
||||
{
|
||||
echo "version<<$delim"
|
||||
printf '%s\n' "$V"
|
||||
echo "$delim"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: compute release tag
|
||||
id: tag
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${VERSION:-}" ]; then
|
||||
echo "::error::release tag would be 'app-v' (empty version output) — aborting"
|
||||
exit 1
|
||||
fi
|
||||
TAG="app-v${VERSION}"
|
||||
if [ "$TAG" = "app-v" ]; then
|
||||
echo "::error::refuse to create invalid tag 'app-v'"
|
||||
exit 1
|
||||
fi
|
||||
echo "value=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$BODY" ]; then
|
||||
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
|
||||
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
fi
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.value }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
IS_DRAFT: ${{ inputs.draft_release }}
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
|
||||
const prerelease = process.env.IS_PRERELEASE === "true";
|
||||
const draft = process.env.IS_DRAFT === "true";
|
||||
const version = process.env.PACKAGE_VERSION;
|
||||
const name = `Psysonic v${version}`;
|
||||
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,
|
||||
name,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
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: tag,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "windows-latest"
|
||||
args: "--bundles nsis"
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "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
|
||||
- name: write Apple API key (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
|
||||
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
|
||||
- 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 }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
- name: re-sign updater bundle + upload .sig (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
TARGET_ARG='${{ matrix.settings.args }}'
|
||||
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
|
||||
TARGET="aarch64-apple-darwin"
|
||||
ARCH="aarch64"
|
||||
else
|
||||
TARGET="x86_64-apple-darwin"
|
||||
ARCH="x64"
|
||||
fi
|
||||
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected tarball missing: $TARBALL"
|
||||
ls -la "$(dirname "$TARBALL")" || true
|
||||
exit 1
|
||||
fi
|
||||
npx @tauri-apps/cli signer sign "$TARBALL"
|
||||
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
|
||||
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools cmake
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "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
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "$RELEASE_TAG" --clobber
|
||||
|
||||
verify-nix:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: open + auto-merge PR with refreshed lock and hash (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ needs.create-release.outputs.package_version }}"
|
||||
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base "${{ inputs.target_branch }}" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
|
||||
|
||||
bump-main-to-next-dev:
|
||||
if: ${{ inputs.channel == 'release' }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: compute next dev version
|
||||
id: next-dev
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
|
||||
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
|
||||
- name: bump package version in main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
SHOULD_BUMP="$(node -e '
|
||||
const parse = (v) => {
|
||||
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
||||
if (!m) throw new Error(`Invalid semver: ${v}`);
|
||||
const pre = m[4] ?? "";
|
||||
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
|
||||
};
|
||||
const a = parse(process.argv[1]); // current
|
||||
const b = parse(process.argv[2]); // target
|
||||
const keys = ["major", "minor", "patch", "preRank"];
|
||||
for (const k of keys) {
|
||||
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
|
||||
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
|
||||
}
|
||||
process.stdout.write("false");
|
||||
' "$CURRENT_VERSION" "$TARGET_VERSION")"
|
||||
if [[ "$SHOULD_BUMP" != "true" ]]; then
|
||||
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: open PR with dev bump (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dev version bump required."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
SAFE_VERSION="${VERSION//./-}"
|
||||
SAFE_VERSION="${SAFE_VERSION//\//-}"
|
||||
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(release): bump main to ${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(release): bump main to ${VERSION}" \
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Validate Main Green CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate"
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- name: validate required checks
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
|
||||
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const branch = process.env.TARGET_BRANCH || "main";
|
||||
const groups = String(process.env.REQUIRED_CONTEXTS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((group) =>
|
||||
group
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const runId = String(context.runId);
|
||||
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
|
||||
const sha = branchResp.data.commit.sha;
|
||||
core.info(`Validating checks for ${branch} @ ${sha}`);
|
||||
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
|
||||
|
||||
const newestByName = new Map();
|
||||
for (const c of checks) {
|
||||
const key = c.name;
|
||||
const prev = newestByName.get(key);
|
||||
if (!prev) {
|
||||
newestByName.set(key, c);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
|
||||
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newestByName.set(key, c);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const alternatives of groups) {
|
||||
let ok = false;
|
||||
const altNotes = [];
|
||||
for (const name of alternatives) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
altNotes.push(`${name}: missing`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== "completed") {
|
||||
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
|
||||
continue;
|
||||
}
|
||||
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
if (!ok) {
|
||||
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = groups
|
||||
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
|
||||
.join(", ");
|
||||
core.info(`All required checks for ${branch} are green (${summary}).`);
|
||||
+584
-10
@@ -5,39 +5,613 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **⚠️ Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
|
||||
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
|
||||
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
|
||||
>
|
||||
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
|
||||
|
||||
## [1.34.16] - 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI — Updater signature upload** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: tauri-action on macOS produces the `.app.tar.gz.sig` minisign signature locally but does not upload it as a release asset for cross-target builds, which caused the `latest.json` manifest generator to fail (no signature to embed). An explicit post-build step now finds the `.sig` under `src-tauri/target/*/release/bundle/macos/` and uploads it with the expected filename (`Psysonic_aarch64.app.tar.gz.sig` / `Psysonic_x64.app.tar.gz.sig`).
|
||||
## [1.44.0] - 2026-04-29
|
||||
|
||||
## [1.34.15] - 2026-04-18
|
||||
## Highlights
|
||||
|
||||
- **Orbit Listening Sessions** — synchronized multi-user listening for Psysonic users, including host/guest roles, queue mirroring, track suggestions, participant strip, host-presence handling and full in-app help.
|
||||
- **Loudness Normalization** — EBU R128 / LUFS analysis with persistent cache, configurable target level and improved waveform data from the same analysis pass.
|
||||
- **Now Playing Dashboard** — a richer, customizable dashboard with draggable and resizable cards, artist context, album tracklist, credits, tour dates and discography.
|
||||
- **Tracks Hub** — a new full-library track-level view with random discovery, searchable browsing and Navidrome-native sorting.
|
||||
- **Smart Playlists** — first-class Navidrome smart playlist creation, editing and management directly inside the Playlists page.
|
||||
- **Share Links / Magic Strings** — share servers, tracks, albums, artists and queues through Psysonic magic strings.
|
||||
- **Lucky Mix** — instant AudioMuse-powered mixes based on listening history, ratings and similar-song discovery.
|
||||
- **Settings Overhaul** — cleaner tab structure, accordion sections, in-page search and better grouping of integrations and personalization options.
|
||||
- **Playlist Suggestion Preview** — audition playlist suggestions with 30-second previews, explicit play-next controls and deliberate add-to-playlist actions.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Added / New Features
|
||||
|
||||
### Orbit — Multi-User Listen-Together
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#304](https://github.com/Psychotoxical/psysonic/pull/304)**
|
||||
|
||||
Orbit introduces synchronized listening sessions for multiple Psysonic users. A host starts a session, shares a magic-string invite, and guests can join to mirror the host's queue, current track and playback position.
|
||||
|
||||
Guests can suggest individual tracks instead of replacing the full queue. Hosts can approve suggestions manually or enable auto-approve. Orbit also includes a live participant strip in the queue, per-guest mute, host-presence detection, automatic handling of stalled sessions and a detailed 9-section in-app help modal.
|
||||
|
||||
Orbit is integrated across Psysonic's interactive song surfaces, including Tracks Hub, Albums, Playlists, Random Mix, Favorites, Artist pages, Search and Advanced Search. When a session is active, song actions are routed through Orbit so guests suggest tracks and hosts enqueue them correctly.
|
||||
|
||||
The feature includes full i18n coverage across all supported locales and end-to-end documentation in `ORBIT.md`.
|
||||
|
||||
> Available in **1.44 RC1**.
|
||||
|
||||
### Loudness Normalization — EBU R128 / LUFS
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh) and [@Psychotoxical](https://github.com/Psychotoxical), PRs [#315](https://github.com/Psychotoxical/psysonic/pull/315), [#317](https://github.com/Psychotoxical/psysonic/pull/317), [#326](https://github.com/Psychotoxical/psysonic/pull/326), [#333](https://github.com/Psychotoxical/psysonic/pull/333)**
|
||||
|
||||
Psysonic now supports integrated-loudness analysis using LUFS and applies per-track gain so material mastered at different levels lines up more consistently at a user-chosen target loudness.
|
||||
|
||||
Analysis results are cached in SQLite, so cold-cache analysis happens once per track and later playback reuses the stored measurement without repeating the heavy analysis work. The available LUFS targets are `-16`, `-14`, `-12` and `-10 LUFS`, with `-12 LUFS` as the default.
|
||||
|
||||
Normalization now lives in one section under Settings → Audio → Normalization, with Off, ReplayGain and LUFS as mutually exclusive modes.
|
||||
|
||||
Until a track has a stored LUFS measurement, Psysonic applies pre-analysis attenuation using a `-14 LUFS` reference calibration in storage, while the active target is reflected as an effective dB value in both the audio engine and the UI. Legacy saved values are migrated on rehydrate.
|
||||
|
||||
Follow-up work stabilized the LUFS analysis loop, serialized CPU-heavy seed and analysis work, kept queue target, pre-trim and ReplayGain IPC in sync when the target or pre-analysis state changes, including reseeding when analysis was cleared but waveform cache data still existed, and refined queue/settings behavior and copy.
|
||||
|
||||
The same analysis path now also provides richer waveform data through mixed mean/peak waveform bins without requiring a second full decode.
|
||||
|
||||
### Now Playing — Customizable Info Dashboard
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#266](https://github.com/Psychotoxical/psysonic/pull/266), [#267](https://github.com/Psychotoxical/psysonic/pull/267)**
|
||||
|
||||
The Now Playing page has been rebuilt from a flat card list into a two-column info dashboard focused on context, metadata and discovery.
|
||||
|
||||
The page now includes a richer cover hero, release-age information, technical badges, Last.fm love state, lyrics toggle, star rating and per-user play count. The dashboard can show a sliding-window album tracklist, top songs by the same artist, OpenSubsonic credits, artist biography, discography and Bandsintown tour dates.
|
||||
|
||||
Cards are draggable and resizable, and the layout is persisted per user. The implementation also includes memoized cards and TTL caches for song metadata, artist info, top songs, discography, Bandsintown and Last.fm data to avoid unnecessary refetching during same-artist playback.
|
||||
|
||||
### Tracks — Full Library Hub Page
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#300](https://github.com/Psychotoxical/psysonic/pull/300), closes [#299](https://github.com/Psychotoxical/psysonic/issues/299)**
|
||||
|
||||
Psysonic now has a dedicated Tracks page between All Albums and Build a Mix. Instead of browsing only through albums, users can explore the library directly at track level.
|
||||
|
||||
The page includes a rerollable “Track of the moment” hero, a random-pick rail and a virtualized, paginated browse list with search. On Navidrome, browsing uses the native `/api/song?_sort=title&_order=ASC` endpoint for proper A–Z sorting, with a graceful fallback for non-Navidrome servers.
|
||||
|
||||
A new `enqueueAndPlay()` helper appends a track to the queue and jumps to it instead of replacing the queue.
|
||||
|
||||
### Genres — Tag Cloud Refactor
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#311](https://github.com/Psychotoxical/psysonic/pull/311)**
|
||||
|
||||
The Genres page has been redesigned as a compact, flowing tag cloud. This replaces the previous large SVG-card grid, which could freeze the WebKitGTK renderer on large libraries.
|
||||
|
||||
Genre pills are sized by album count, use deterministic palette colors and open instantly even with hundreds of genres. Pagination is no longer needed.
|
||||
|
||||
### Now Playing Info Tab in Queue Panel
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#244](https://github.com/Psychotoxical/psysonic/pull/244)**
|
||||
|
||||
The right-side queue panel now includes a third tab next to Queue and Lyrics. The new Info tab shows context for the currently playing track.
|
||||
|
||||
It includes an artist card with biography and image from Subsonic `getArtistInfo`, song contributors from OpenSubsonic metadata, and optional Bandsintown tour dates. Bandsintown integration is opt-in and includes privacy information when disabled.
|
||||
|
||||
### Discover Songs Rail on Mainstage
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#301](https://github.com/Psychotoxical/psysonic/pull/301)**
|
||||
|
||||
The Home page now includes a new Discover Songs rail, surfacing fresh track-level recommendations alongside the existing album-focused sections.
|
||||
|
||||
### ReplayGain — Auto Mode
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#242](https://github.com/Psychotoxical/psysonic/pull/242)**
|
||||
|
||||
ReplayGain now has an Auto mode. Psysonic automatically chooses album gain when the current queue is a contiguous album and track gain for shuffled or mixed playback.
|
||||
|
||||
This removes the need to manually switch normalization behavior between full-album listening and mixed queues.
|
||||
|
||||
### Settings — Refactor, Accordions and Search
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#259](https://github.com/Psychotoxical/psysonic/pull/259), [#263](https://github.com/Psychotoxical/psysonic/pull/263), [#264](https://github.com/Psychotoxical/psysonic/pull/264), closes [#257](https://github.com/Psychotoxical/psysonic/issues/257)**
|
||||
|
||||
Settings have been reorganized into clearer thematic tabs: Servers, Library, Audio, Lyrics, Appearance, Personalisation, Integrations, Input, Storage, System and Users.
|
||||
|
||||
Tabs are now split into accordion sections for a calmer landing view. A new in-page search can expand matching sections automatically, search across tabs, support keyboard navigation and flash matching results.
|
||||
|
||||
Integrations such as Last.fm, Discord, Bandsintown and Now-Playing Share now live together in the Integrations tab. Sidebar, Artist and Home customizers live under Personalisation. Contributors are shown in a dedicated System section.
|
||||
|
||||
### Playlists — Suggestion Preview UX
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#365](https://github.com/Psychotoxical/psysonic/pull/365)**
|
||||
|
||||
Playlist suggestions introduce a dedicated preview workflow for auditioning recommended songs before adding them to a playlist or sending them to the queue.
|
||||
|
||||
Each suggestion includes a 30-second preview action with an animated progress ring. Previews play through a separate HTML5 audio element: the main player pauses during the preview, resumes when the preview ends, and cancels cleanly when playback is controlled manually.
|
||||
|
||||
An explicit Play Next button inserts the suggestion after the current queue item and starts playback there. Double-clicking a suggestion row adds it to the playlist, matching the existing plus button, while single-clicking the row intentionally does nothing to avoid accidental actions.
|
||||
|
||||
Adding a suggestion preserves the playlist scroll position. The preview path is also built to behave reliably on WebKitGTK and applies LUFS pre-analysis attenuation when loudness normalization is enabled, so previews stay closer to normal playback volume.
|
||||
|
||||
The feature includes updated i18n coverage across all supported locales.
|
||||
|
||||
### Artist Page — User-Configurable Sections
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#254](https://github.com/Psychotoxical/psysonic/pull/254), closes [#252](https://github.com/Psychotoxical/psysonic/issues/252) from [@bcorporaal](https://github.com/bcorporaal)**
|
||||
|
||||
The Artist Detail page now lets users reorder and hide/show individual sections such as Top Songs, Albums, Similar Artists and Bio.
|
||||
|
||||
The layout is persisted per user and can be configured from Settings → Personalisation.
|
||||
|
||||
### Album Enqueue Actions
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#256](https://github.com/Psychotoxical/psysonic/pull/256), closes [#253](https://github.com/Psychotoxical/psysonic/issues/253) from [@bcorporaal](https://github.com/bcorporaal)**
|
||||
|
||||
Album covers now include an Enqueue hover action next to Play. Album context menus and the multi-select toolbar also gained Enqueue actions.
|
||||
|
||||
All three actions append to the existing queue instead of replacing it.
|
||||
|
||||
### Playlists — Bulk Delete and Duplicate Confirmation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#290](https://github.com/Psychotoxical/psysonic/pull/290), [#329](https://github.com/Psychotoxical/psysonic/pull/329)**
|
||||
|
||||
The Playlists page now includes a bulk-delete action while in selection mode. When adding a song that already exists in a playlist, Psysonic now asks for confirmation instead of silently adding a duplicate.
|
||||
|
||||
### Search — Unified SongRow and Paginated Song Results
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#303](https://github.com/Psychotoxical/psysonic/pull/303)**
|
||||
|
||||
Search results, Advanced Search and the Tracks Hub now share one `SongRow` component.
|
||||
|
||||
Song results in search pages are paginated with infinite scroll instead of being limited to a single capped batch.
|
||||
|
||||
### Login — Language Picker
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#328](https://github.com/Psychotoxical/psysonic/pull/328)**
|
||||
|
||||
The login page now includes a language picker so first-run users can choose their language before they have a server profile where that preference can be saved.
|
||||
|
||||
### Lucky Mix
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PRs [#278](https://github.com/Psychotoxical/psysonic/pull/278), [#332](https://github.com/Psychotoxical/psysonic/pull/332)**
|
||||
|
||||
Lucky Mix builds an instant queue from listening history, ratings and AudioMuse similar-song batches. It skips low-rated tracks, honors the active library scope and can be cancelled while building.
|
||||
|
||||
The feature appears in the sidebar, mobile overlay and Mix landing page when AudioMuse is available. Follow-up work corrected the rating filter against Navidrome's OpenSubsonic rating fields.
|
||||
|
||||
### Library Deep Links — `psysonic2-` Share Scheme
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#261](https://github.com/Psychotoxical/psysonic/pull/261)**
|
||||
|
||||
Psysonic can now share tracks, albums, artists and queues through `psysonic2-` magic strings. Pasting one into the app switches to the matching server and plays or navigates to the shared item.
|
||||
|
||||
Queue links resolve tracks in chunks and report how many tracks could be played or skipped when the receiving server is missing items.
|
||||
|
||||
### Magic-String Server Invites and Navidrome Admin Sharing
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#258](https://github.com/Psychotoxical/psysonic/pull/258)**
|
||||
|
||||
Navidrome admins can generate a `psysonic1-` invite string that pre-fills the add-server form for another user. The add-user dialog also validates library access so non-admin users cannot be saved without any libraries.
|
||||
|
||||
### Sleep Timer — Circular Ring UI
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#272](https://github.com/Psychotoxical/psysonic/pull/272)**
|
||||
|
||||
The sleep timer and delayed-start UI now use a circular progress ring around the play/pause button, with an in-button countdown and redesigned timer modal.
|
||||
|
||||
The updated UI works across PlayerBar, FullscreenPlayer and MobilePlayerView.
|
||||
|
||||
### Queue — Undo/Redo with Hotkeys
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#331](https://github.com/Psychotoxical/psysonic/pull/331)**
|
||||
|
||||
Queue edits can now be undone and redone with Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z. Queue snapshots include order, current track, playback position, play/pause state and scroll offset.
|
||||
|
||||
Restoring a snapshot preserves playback when possible and resyncs the audio engine when needed.
|
||||
|
||||
### Sidebar — Long-Press Drag to Reorder
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#269](https://github.com/Psychotoxical/psysonic/pull/269)**
|
||||
|
||||
Sidebar items can now be reordered by long-press dragging. Dropping an item outside the sidebar hides it, using the same hidden-state model as the Settings customizer.
|
||||
|
||||
### Playlists — Navidrome Smart Playlists
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#289](https://github.com/Psychotoxical/psysonic/pull/289), proposed by bequbed on Discord**
|
||||
|
||||
Navidrome smart playlists are now managed directly inside the Playlists page. Users can create, edit and delete smart playlists using the same flow as regular playlists, with a dedicated rule editor for smart playlist parameters.
|
||||
|
||||
Smart playlists have distinct icons and support filters such as genre include/exclude, year ranges, ratings and metadata rules.
|
||||
|
||||
### Song Info — Copy Fields via Double-Click
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#323](https://github.com/Psychotoxical/psysonic/pull/323)**
|
||||
|
||||
Double-clicking any field in the Song Info modal copies that value to the clipboard.
|
||||
|
||||
### Mobile UI Overhaul
|
||||
|
||||
**By [@kilyabin](https://github.com/kilyabin), PR [#238](https://github.com/Psychotoxical/psysonic/pull/238)**
|
||||
|
||||
A broad pass over mobile and narrow-viewport layouts, including the sidebar drawer, player view, queue, search and detail pages.
|
||||
|
||||
### Logging — Runtime Levels and Debug Export
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#241](https://github.com/Psychotoxical/psysonic/pull/241)**
|
||||
|
||||
Settings now allow switching between `info` and `debug` log levels at runtime. Users can also export the current debug log for bug reports.
|
||||
|
||||
### CLI — Logs Subcommand
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#337](https://github.com/Psychotoxical/psysonic/pull/337)**
|
||||
|
||||
The `psysonic` CLI now includes a `logs` subcommand with `tail` and `--follow` support.
|
||||
|
||||
---
|
||||
|
||||
## Changed / Improved
|
||||
|
||||
### Performance Suite
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#245](https://github.com/Psychotoxical/psysonic/pull/245), [#246](https://github.com/Psychotoxical/psysonic/pull/246), [#247](https://github.com/Psychotoxical/psysonic/pull/247), [#248](https://github.com/Psychotoxical/psysonic/pull/248), [#249](https://github.com/Psychotoxical/psysonic/pull/249), [#250](https://github.com/Psychotoxical/psysonic/pull/250), [#251](https://github.com/Psychotoxical/psysonic/pull/251)**
|
||||
|
||||
A coordinated performance pass improved several expensive areas of the app:
|
||||
|
||||
- Search thumbnails now use `CachedImage` to reduce redundant image loading.
|
||||
- Device Sync parallelizes `getAlbum` calls when syncing artist sources.
|
||||
- Album infinite-scroll prefetching starts earlier for smoother scrolling.
|
||||
- Resolved lyrics are persisted to IndexedDB and survive app restarts.
|
||||
- Rarely used pages are lazy-loaded and vendor chunks are split.
|
||||
- Artist list filtering and grouping are memoized.
|
||||
- Genre sorting and rendering were optimized, later replaced by the new tag-cloud layout.
|
||||
|
||||
### Navidrome Admin API Resilience
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#260](https://github.com/Psychotoxical/psysonic/pull/260)**
|
||||
|
||||
The Navidrome admin REST client is more resilient against flaky upstream behavior. Psysonic now forces HTTP/1.1 for these calls, requires TLS 1.2+, retries transient errors and shows a cleaner UI retry surface instead of raw error toasts.
|
||||
|
||||
### Linux Audio Device Selection
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#288](https://github.com/Psychotoxical/psysonic/pull/288)**
|
||||
|
||||
On Linux, Psysonic now prefers `pipewire` and then `pulse` before falling back to the default CPAL device resolution.
|
||||
|
||||
This avoids cases where the default device resolves to a null sink on PipeWire-based systems. Explicit user device selection in Settings is still respected.
|
||||
|
||||
### Tauri Devtools Behavior
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#307](https://github.com/Psychotoxical/psysonic/pull/307), [#310](https://github.com/Psychotoxical/psysonic/pull/310)**
|
||||
|
||||
Devtools no longer auto-open during development and are disabled in production builds. In dev builds, they can still be opened with Ctrl+Shift+I.
|
||||
|
||||
### Dependency Updates
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#306](https://github.com/Psychotoxical/psysonic/pull/306)**
|
||||
|
||||
Updated `rustls-webpki` and `postcss`.
|
||||
|
||||
### Subsonic HTTP User-Agent Alignment
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#235](https://github.com/Psychotoxical/psysonic/pull/235)**
|
||||
|
||||
Rust-side HTTP requests now send the same User-Agent as the main WebView requests, helping servers, rate limiters and reverse proxies identify Psysonic consistently.
|
||||
|
||||
---
|
||||
|
||||
## Fixed Since Preview / RC
|
||||
|
||||
### Cross-Device Resume Position
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#318](https://github.com/Psychotoxical/psysonic/pull/318)**
|
||||
|
||||
Server-side play-queue position could remain at the start of a long track if the app was closed without a seek or track change. Psysonic now flushes queue position through a playback heartbeat, on pause and through shared quit paths.
|
||||
|
||||
### Image Cache Blob URL Lifetime
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#313](https://github.com/Psychotoxical/psysonic/pull/313), [#321](https://github.com/Psychotoxical/psysonic/pull/321)**
|
||||
|
||||
The shared blob URL cache could revoke an image URL while another component still used it, causing `blob:` load errors. Cover URLs are now reference-counted and only revoked after the last consumer unmounts.
|
||||
|
||||
### Queue Scroll Context
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#314](https://github.com/Psychotoxical/psysonic/pull/314)**
|
||||
|
||||
Clicking a track in the queue no longer snaps the queue list back to the current now-playing position when the user is browsing elsewhere in the queue.
|
||||
|
||||
### Mini Player Saved Position
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#280](https://github.com/Psychotoxical/psysonic/pull/280)**
|
||||
|
||||
Saved mini-player coordinates are now checked against the current monitor layout. If the saved monitor no longer exists, the position is discarded instead of opening the mini player off-screen.
|
||||
|
||||
### Mini Player Volume Popover
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#279](https://github.com/Psychotoxical/psysonic/pull/279)**
|
||||
|
||||
The mini-player volume popover is now portal-rendered so it can no longer be clipped by the mini-player window bounds.
|
||||
|
||||
### Gapless Volume Handling
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#277](https://github.com/Psychotoxical/psysonic/pull/277)**
|
||||
|
||||
Volume changes prepared for the next gapless track are now deferred until the actual transition, preventing them from affecting the still-playing track.
|
||||
|
||||
### Search Context Menu and Live Search Behavior
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#298](https://github.com/Psychotoxical/psysonic/pull/298), [#302](https://github.com/Psychotoxical/psysonic/pull/302)**
|
||||
|
||||
Live-search clicks now enqueue and play correctly, right-clicking repositions the existing context menu instead of opening a second one, and artist/album rows in search results now support the expected context menu behavior.
|
||||
|
||||
### Server Switch Playback and Home Refresh
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#262](https://github.com/Psychotoxical/psysonic/pull/262), [#291](https://github.com/Psychotoxical/psysonic/pull/291)**
|
||||
|
||||
Switching servers no longer tears down playback when the same track is still cached locally. The Home page also refreshes correctly after changing the active server.
|
||||
|
||||
### Queue Panel Persistence and LUFS Cleanup
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#336](https://github.com/Psychotoxical/psysonic/pull/336)**
|
||||
|
||||
The queue panel's open/closed state is now persisted across restarts. A dead loudness-store binding left over from the LUFS branch was removed.
|
||||
|
||||
### Toolbar Icons
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#284](https://github.com/Psychotoxical/psysonic/pull/284), closes [#274](https://github.com/Psychotoxical/psysonic/issues/274)**
|
||||
|
||||
The Gapless and Infinite Queue toolbar icons now match their actions correctly.
|
||||
|
||||
### Pointer Gesture Performance Regression
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh) and [@Psychotoxical](https://github.com/Psychotoxical), PRs [#281](https://github.com/Psychotoxical/psysonic/pull/281), [#282](https://github.com/Psychotoxical/psysonic/pull/282), [#283](https://github.com/Psychotoxical/psysonic/pull/283)**
|
||||
|
||||
A short-lived performance spike caused by overlapping pointer gestures was fixed after an initial patch, revert and clean re-landing.
|
||||
|
||||
### pingWithCredentials Diagnostics
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#312](https://github.com/Psychotoxical/psysonic/pull/312)**
|
||||
|
||||
Credential ping failures are now logged instead of failing silently, making server/auth issues easier to diagnose from debug logs.
|
||||
|
||||
### Fullscreen WebKitGTK Halo
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#239](https://github.com/Psychotoxical/psysonic/pull/239)**
|
||||
|
||||
The fullscreen mesh-blob background no longer shows a faint halo ring on WebKitGTK.
|
||||
|
||||
### Custom Titlebar Glyphs
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#243](https://github.com/Psychotoxical/psysonic/pull/243)**
|
||||
|
||||
The macOS-style traffic-light glyphs in the custom titlebar are now hidden at rest and only appear on hover.
|
||||
|
||||
### Discord Rich Presence Diagnostics
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#330](https://github.com/Psychotoxical/psysonic/pull/330)**
|
||||
|
||||
Debug-only logging was added for the Discord Rich Presence IPC path to make connection issues easier to inspect.
|
||||
|
||||
### Streaming Seek UI Freeze
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#236](https://github.com/Psychotoxical/psysonic/pull/236), closes [#218](https://github.com/Psychotoxical/psysonic/issues/218)**
|
||||
|
||||
Seeking on streaming tracks no longer blocks the render thread or causes the progress indicator to snap back during the pending seek.
|
||||
|
||||
### Windows WebView2 Hidden-Window Activity
|
||||
|
||||
**By [@peri4ko](https://github.com/peri4ko), PR [#273](https://github.com/Psychotoxical/psysonic/pull/273), follow-up by [@Psychotoxical](https://github.com/Psychotoxical), PR [#276](https://github.com/Psychotoxical/psysonic/pull/276)**
|
||||
|
||||
Hidden Windows WebView2 windows now pause more UI work while hidden, including CSS animations and selected periodic tasks.
|
||||
|
||||
### Linux Wayland Drag Ghost
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#268](https://github.com/Psychotoxical/psysonic/pull/268)**
|
||||
|
||||
Wayland drag operations no longer leave GTK drag proxies or PsyDnD ghost elements behind after drag end or cancel.
|
||||
|
||||
### Queue Panel Resize Handle
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#324](https://github.com/Psychotoxical/psysonic/pull/324)**
|
||||
|
||||
The queue panel resize handle is no longer blocked by the main page scroll hit-test area at certain viewport widths.
|
||||
|
||||
### Album Queueing and Smart Playlist Targets
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#322](https://github.com/Psychotoxical/psysonic/pull/322)**
|
||||
|
||||
Queueing a full album after the current track now behaves reliably. Smart playlists are also filtered out of manual “Add to playlist” target lists.
|
||||
|
||||
### Analysis Cache Logging
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#320](https://github.com/Psychotoxical/psysonic/pull/320)**
|
||||
|
||||
Waveform path logging in the analysis cache now reports the correct LUFS pipeline state.
|
||||
|
||||
### UI Overlay and Scroll Fixes
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#255](https://github.com/Psychotoxical/psysonic/pull/255)**
|
||||
|
||||
Overlay-scrollbar state reporting, column-resizer hit testing and Linux mini-player mouse-wheel scrolling were improved.
|
||||
|
||||
|
||||
### Contributors
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Loudness Normalization headline (#315), LUFS stabilisation (#326), Lucky Mix (#278), Queue undo/redo (#331), Sleep Timer ring UI (#272), Library deep links (#261), Magic-string invites (#258), CLI logs subcommand (#337), runtime log levels + debug log export (#241), smart playlists workflow (#289), several streaming + UI fixes.
|
||||
- [@Psychotoxical](https://github.com/Psychotoxical) — Orbit (#304), Now-Playing dashboard (#266 / #267), Tracks Hub (#300), Genres tag-cloud (#311), Settings refactor (#259), perf suite (#245–#251), and most of the cross-device + admin-API hardening work.
|
||||
- [@peri4ko](https://github.com/peri4ko) — Windows WebView2 idle hooks (#273).
|
||||
- [@kilyabin](https://github.com/kilyabin) — Mobile UI overhaul (#238).
|
||||
|
||||
## [1.43.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
- **User Management — admin-gated tab in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the active server is Navidrome and the logged-in user is an admin, Settings gets a new "Users" tab. Lists every user with username, display name, email, last-access timestamp and assigned libraries. Add / edit / delete via Navidrome's native REST API (`/api/user`) using a Bearer token obtained from `/auth/login` — the Subsonic API doesn't expose this, so non-Navidrome servers don't get the tab.
|
||||
|
||||
## [1.34.14] - 2026-04-18
|
||||
- **User Management — per-user library assignment** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#222](https://github.com/Psychotoxical/psysonic/pull/222))*: Mirrors the Navidrome web client. Non-admin users get a checkbox picker showing every library on the server; the picker is hidden for admins (Navidrome auto-grants them access to all libraries). Inline validation prevents saving a non-admin with zero libraries.
|
||||
|
||||
- **User Management — last-access timestamp per user** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Each row shows when the user was last active, formatted as a localised relative time (`vor 5 Min.`, `2h ago`, etc.) using `Intl.RelativeTimeFormat`. Tooltip carries the absolute timestamp. Users who have never logged in show "Never".
|
||||
|
||||
- **Seekable streaming + instant local playback — first cut** *(by [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh))*: New `RangedHttpSource` + `LocalFileSource` audio backends. Seek operations on remote tracks now issue HTTP `Range` requests instead of restarting the stream from byte 0, and locally cached files start playing instantly without going through the HTTP path at all. WaveformSeek commits the seek on mouseup (not during drag), and progress ticks during a drag are ignored so the playhead doesn't jitter back and forth. **Note:** the underlying seek/buffer behaviour is not fully sorted yet — expect follow-up changes in the next releases as edge cases (slow proxies, partial-content retries, codec-specific quirks) get ironed out.
|
||||
|
||||
- **Mini player — queue-style meta block, action toolbar, vertical volume slider** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The mini's right column gets a richer track-info block matching the queue panel's styling. A dedicated action toolbar (love / queue / context menu) sits below the transport. The horizontal volume slider is replaced by a tall vertical one on the right edge for a more compact footprint.
|
||||
|
||||
- **Settings — compact spacing pass + row hover affordance** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#223](https://github.com/Psychotoxical/psysonic/pull/223))*: Section margins, card padding and divider spacing all tightened — every Settings tab fits more content per viewport. Each toggle row gains a subtle accent-tinted hover background that bleeds to the card edges so the active row is visually obvious.
|
||||
|
||||
- **Floating player bar — toggleable variant** *(by [@kveld9](https://github.com/kveld9), PR [#216](https://github.com/Psychotoxical/psysonic/pull/216))*: Settings → Appearance → "Floating player bar" turns the player bar into a floating, rounded panel that sits above the page content with a margin around all four edges. Off by default. Solid background, works with every theme.
|
||||
|
||||
- **Floating player bar — liquid-glass look on macOS and Windows** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the floating bar is enabled, macOS and Windows users get a gentler glass-effect background (subtle blur + tint) on top of @kveld9's solid variant. Linux keeps the solid look — WebKitGTK's `backdrop-filter` cost is too high for an always-visible panel. A new `data-platform` attribute on `<html>` is the generic platform-gate that other CSS can hook into.
|
||||
|
||||
- **NVIDIA proprietary driver — DMA-BUF auto-disabled on Linux** *(by [@kveld9](https://github.com/kveld9), PR [#217](https://github.com/Psychotoxical/psysonic/pull/217), refactored by [@Psychotoxical](https://github.com/Psychotoxical))*: Detects the NVIDIA proprietary driver at startup and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` for the WebKitGTK process, avoiding rendering glitches that show up specifically on that combo. Confirmed via blind A/B testing — only the proprietary driver is targeted; Nouveau / AMD / Intel are not touched.
|
||||
|
||||
- **Lyrics — cubic ease-out scroll animator** *(by [@kilyabin](https://github.com/kilyabin), PRs [#214](https://github.com/Psychotoxical/psysonic/pull/214) / [#215](https://github.com/Psychotoxical/psysonic/pull/215))*: The lyrics auto-scroll animation is replaced by a smoother cubic ease-out curve (renamed internally from `springScroll` to `easeScroll`). Active line transitions are noticeably less jerky on long line-spacing changes.
|
||||
|
||||
- **Fullscreen lyrics — fade bottom edge of plain lyrics scroll viewport** *(by [@kilyabin](https://github.com/kilyabin))*: Plain (unsynced) lyrics in the fullscreen player now fade out at the bottom of the scroll viewport via a `mask-image` gradient, matching the existing fade on the synced-lyrics overlay.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mini player — main window minimises on open + width cap on non-tiling WMs** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Opening the mini now reliably minimises the main window (previously hit-or-miss on some WMs), and the mini's width is capped on non-tiling Linux WMs so it doesn't open larger than its intended footprint when the user's WM hands it the full screen.
|
||||
|
||||
- **Artist page — Top Songs continues playback past the last track** *(by [@kveld9](https://github.com/kveld9), PR [#220](https://github.com/Psychotoxical/psysonic/pull/220))*: Playing a song from the Artist page's Top Songs row no longer stops after the row's last track — the queue continues into the surrounding context as intended.
|
||||
|
||||
- **Padding fixes across several pages** *(by [@kveld9](https://github.com/kveld9), PR [#221](https://github.com/Psychotoxical/psysonic/pull/221))*: Layout polish, mostly aligning content to the page-level container padding instead of the inner card padding.
|
||||
|
||||
- **Jayfin theme — WCAG AA contrast fixes for nav + primary buttons** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Hover and active states on the Jayfin theme's sidebar nav items and primary buttons now pass WCAG AA contrast against the underlying background.
|
||||
|
||||
- **Lyrics — sidebar lyrics with YouLy+ source render as a single line** *(by [@kilyabin](https://github.com/kilyabin))*: Lines from the YouLyrics+ source were being split across multiple visual lines in the QueuePanel lyrics pane. Now collapse onto one line as intended.
|
||||
|
||||
- **Settings → Lyrics Sources — drag-and-drop survives mode toggle** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Reordering lyrics sources via drag-and-drop no longer resets when toggling the synced-vs-plain mode.
|
||||
|
||||
- **Folder browser — auto-contrast text on selected row** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Selected rows in the folder browser now compute text colour from the row's background luminance, so light themes don't paint white-on-white text.
|
||||
|
||||
- **Titlebar — theme-independent traffic-lights + song pill** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The macOS-style traffic-lights and the now-playing pill in the titlebar use fixed colours instead of theme tokens, so they stay legible on every theme without needing per-theme overrides.
|
||||
|
||||
### Reverted
|
||||
|
||||
- **Reverted: fs-player WebKitGTK CPU-cut patch** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: An earlier perf patch in the Fullscreen Player that disabled compositing under WebKitGTK turned out to cause animation regressions in real-world use. Reverted; the original code path is back.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AudioMuse toggle — Alpha badge dropped** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The AudioMuse-AI integration has been stable for several releases; the "Alpha" tag in Settings → Server is removed.
|
||||
|
||||
## [1.42.1] - 2026-04-19
|
||||
|
||||
> **🚨 Critical bug fix for Windows users.** On 1.42.0, opening the mini player on Windows could stall Tauri's event loop: the mini would appear as a blank white window, neither the main window nor the mini could be closed, and the only way out was killing the process via Task Manager. **Please update immediately if you're on Windows 1.42.0.** macOS and Linux were not affected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mini player no longer hangs the app on Windows** *([@Psychotoxical](https://github.com/Psychotoxical))*: Creating the second WebView2 webview lazily from the `open_mini_player` invoke handler reliably froze the app on Windows — the mini opened blank, both windows became unresponsive, and the user had to kill the process from Task Manager. The builder + `main.minimize()` combo racing against WebView2's first paint was the trigger. The mini webview is now pre-built hidden in Tauri's `.setup()` on Windows, so the first open is a pure show/hide instead of creation + minimize. `open_mini_player` is simpler on all platforms, the minimize-main dance around show/hide is skipped on Windows, and Windows also goes back to the native window decorations (the earlier `decorations: false` mini titlebar was part of the hang surface).
|
||||
- **Mini player syncs immediately on first open** *([@Psychotoxical](https://github.com/Psychotoxical))*: With the mini pre-created on Windows, the mount-time `mini:ready` event could race past the main window's bridge listener and leave the mini without a snapshot when the user actually opened it. The mini now also re-emits `mini:ready` on every window focus, so opening the mini always triggers a fresh sync regardless of startup ordering.
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional “Preload mini player” setting on Linux + macOS** *([@Psychotoxical](https://github.com/Psychotoxical))*: Settings → General → App behaviour. Off by default. When enabled, the mini player window is built hidden at app start so the first open is instant instead of waiting a few seconds for WebKit to boot + React to hydrate + the bridge snapshot to arrive. Costs one extra WebKit process in the background permanently (~50–100 MB RAM). Windows always preloads regardless of this toggle — it's how we work around the hang above, not an opt-in feature there.
|
||||
|
||||
## [1.42.0] - 2026-04-19
|
||||
|
||||
> **🛠️ Note on the 1.41.0 jump:** The 1.41.0 tag exists as an internal Draft release on GitHub — it was used to wire up and verify the Cachix substituter pipeline and never went public. **1.42.0 is the first public release after 1.40.0** and consolidates everything that was prepared for 1.41.0 plus the work landed on top in the days since.
|
||||
>
|
||||
> **❄️ Cachix is live for NixOS users.** The `psysonic.cachix.org` substituter is now actually fed by every release. Earlier 1.40.x runs were silently skipping the cache push (see *Fixed* below), so the first user to ask for a given output paid the full compile cost. Starting with 1.42.0, `nix run github:Psychotoxical/psysonic` and the NixOS module both pull the prebuilt closure straight from Cachix — no local Rust + symphonia + libopus build required.
|
||||
|
||||
### Added
|
||||
|
||||
- **Mini player — feature-complete second cut** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: The early-alpha mini from the internal 1.41.0 prep gets the rest of the workflow it was missing.
|
||||
- **Expandable queue panel** with full track list, search-style overlay scrollbar (no width-eating gutter), drag-to-reorder using the existing PsyDnD system, and a localized right-click context menu (Play now / Remove from queue / Open album / Go to artist / Favorite / Song info — all forwarded to the main window via Tauri events so the source-of-truth playerStore stays consistent).
|
||||
- **Custom in-page titlebar** on Windows + Linux with a drag region, the current track title and the queue / pin / open-main / close action icons. macOS keeps the native traffic-lights titlebar so the system look is preserved. The lower toolbar from the alpha is gone — its four buttons live in the titlebar now.
|
||||
- **Persistent geometry**: window position, expanded-queue height and queue-open state all survive an app restart. Position is written to `<app_config_dir>/mini_player_pos.json` on every move (throttled), and re-applied after each show — Linux WMs (Mutter/KWin) re-centre hidden windows on show, so without re-applying the position would be lost on the second open.
|
||||
- **User-bindable keyboard shortcut** in Settings → Shortcuts (`open-mini-player`, default unbound). The same chord toggles between main and mini regardless of which window has focus.
|
||||
- **Layout polish**: cover shrinks 112 → 84 px, the right column gets title / artist / transport in a single block, progress + toolbar take full width.
|
||||
- **Live theme / font / language sync**: changes in the main window propagate to an open mini via the shared localStorage `storage` event — no need to close + re-open the mini after rebinding a shortcut or switching themes.
|
||||
- **Always-on-top reliability fix**: WMs that silently ignore `set_always_on_top(true)` when the flag is "already true" (KWin, certain Mutter releases) get a forced false → true cycle so the constraint is actually re-evaluated. The frontend also re-asserts the pin state on mount and on focus, so the user no longer has to click the pin button twice for it to stick.
|
||||
|
||||
- **Player bar — click-to-toggle duration / remaining time** *(contributed by [@kveld9](https://github.com/kveld9), PR [#212](https://github.com/Psychotoxical/psysonic/pull/212))*: Click the time read-out in the player bar to swap between total duration (`3:45`) and remaining time (`-2:34`). Updates live, persisted to `themeStore.showRemainingTime`. A small swap icon (⇄) and hover highlight signal the interaction.
|
||||
|
||||
- **Queue — ReplayGain in tech strip, expandable badge** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), originally by [@cucadmuh](https://github.com/cucadmuh) in PRs [#196](https://github.com/Psychotoxical/psysonic/pull/196) / [#201](https://github.com/Psychotoxical/psysonic/pull/201) — UX iteration by [@Psychotoxical](https://github.com/Psychotoxical) on cucadmuh's feedback)*: Tracks with ReplayGain metadata now show a small `RG ⌄` pill at the end of the codec/bitrate/sample-rate strip. Hover reveals the values via tooltip; click expands a second line ("ReplayGain · T -8.9 dB · A -11.0 dB · Peak 0.998") that is persisted across sessions. Hides itself for tracks without RG metadata.
|
||||
|
||||
- **Changelog — sidebar banner + dedicated `/whats-new` page** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The auto-popup modal that nagged the user on first launch after each update is replaced by a discreet sidebar banner. Clicking it opens a full `/whats-new` page that renders the latest CHANGELOG section in app — no separate Markdown viewer, no broken links to GitHub.
|
||||
|
||||
- **Favorites — genre column + Top Favorite Artists row** *(Issue [#87](https://github.com/Psychotoxical/psysonic/issues/87), by [@Psychotoxical](https://github.com/Psychotoxical))*: The Favorites tracklist now has a toggleable Genre column (alongside the existing Album column and multi-genre filter). A new horizontally scrolling "Top Favorite Artists" row sits between Radio Stations and Songs, aggregated from starred tracks and sorted by star count. Clicking an artist card narrows the song list to that artist.
|
||||
|
||||
- **Compilation filter on All Albums** *(Issue [#65](https://github.com/Psychotoxical/psysonic/issues/65), by [@Psychotoxical](https://github.com/Psychotoxical))*: A tri-state toggle in the Albums page header (All / Only compilations / Hide compilations) that reads the OpenSubsonic `isCompilation` tag exposed by Navidrome 0.61+. Client-side filter, no additional server calls. Translated into all 8 supported locales.
|
||||
|
||||
- **Sticky header on Albums, New Releases, Artists** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The header row with search/sort/genre/year controls now pins to the top while scrolling, so filters stay reachable without jumping back up. Works the same on all three browse pages.
|
||||
|
||||
- **Device Sync — album artist on both panels** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Album entries in both the library (left) and on-device (right) panels now display `Album · Artist` inline, so sampler discs and self-titled albums are no longer guesswork. Playlists unchanged.
|
||||
|
||||
- **NixOS — first-class flake install guide** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PRs [#209](https://github.com/Psychotoxical/psysonic/pull/209) / [#210](https://github.com/Psychotoxical/psysonic/pull/210))*: A new top-level `nixos-install.md` walks through adding Psysonic as a flake input, installing via `environment.systemPackages` / `home.packages`, and wiring up the public `psysonic.cachix.org` substituter so every NixOS user pulls prebuilt binaries. README links to it directly.
|
||||
|
||||
- **README — AppImage in the Linux install options + Cachix badge** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Linux install section now lists AppImage alongside `.deb`, `.rpm`, AUR and Nix flakes. A Cachix badge on the README header signals that NixOS users get prebuilt binaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Genre filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The inline tagbox + dropdown (capped at 60 entries, ate header space when expanded) is replaced by a compact button that opens a portal-rendered popover with a search field and the full scrollable list of genres. Selected genres sort to the top. Used on Albums, New Releases, Random Albums and Favorites.
|
||||
|
||||
- **Year filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The From/To number inputs in the Albums header became a single button with a popover mirroring the genre filter pattern. When the filter is active, the button shows the range (e.g. `2020–2024`) in accent colour.
|
||||
|
||||
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`A–Z (Album)`, `A–Z (Artist)`) collapse into one dropdown button showing the current choice. Generic `SortDropdown` component, reusable for other pages.
|
||||
|
||||
- **Device Sync — album/playlist meta inline** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `BrowserRow` renders secondary info inline with a `·` separator in muted colour instead of a separate right-aligned column, matching the on-device panel's format.
|
||||
|
||||
- **README — Arch/AUR fold-up** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Arch / AUR install instructions are folded into the Linux install section so the README stops scrolling forever.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Player bar — black-flash on WebKitGTK** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Linux users occasionally saw the entire player bar paint fully black for one frame when an unrelated layer elsewhere on the page invalidated. `contain: layout paint` makes the bar its own paint boundary so it can no longer be pulled into a surrounding dirty rect. No-op on platforms that don't exhibit the flash (Wayland-with-GPU, Chromium webviews on Windows / macOS).
|
||||
|
||||
- **Player bar — time-toggle tooltip uses the in-app TooltipPortal** *(follow-up to PR [#212](https://github.com/Psychotoxical/psysonic/pull/212), by [@Psychotoxical](https://github.com/Psychotoxical))*: The new time-swap control was rendering the native browser `title=` tooltip (unstyled OS popup, ignored by every other control). Switched to `data-tooltip="…"` so it matches every other player-bar tooltip.
|
||||
|
||||
- **Fullscreen player — lyrics menu toggle + readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Re-clicking the mic icon now actually closes the lyrics settings panel instead of the outside-click handler closing it and the click re-opening it — the trigger button is excluded from the outside-check. The panel itself is now a solid surface (no backdrop blur, near-opaque background, higher-contrast button text) so settings remain readable over the busy fullscreen background.
|
||||
|
||||
- **i18n — ArtistCardLocal album count** *(contributed by [@cucadmuh](https://github.com/cucadmuh))*: Local artist cards were rendering the album count with hardcoded German (`Album` / `Alben`). Switched to the existing plural-aware `artists.albumCount` key which already covers all 8 locales including Russian Slavic plurals.
|
||||
|
||||
- **Release CI — Cachix never receiving the psysonic closure** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `cachix-action` installs its post-build hook via `NIX_USER_CONF_FILES`, but the Determinate Nix daemon that runs the actual builds reads the system nix.conf — so the hook never fired. Only a couple of early prep paths ever reached the cache, never the compiled `psysonic` output. The release workflow now pushes the full closure explicitly after `nix build`; Cachix dedupes against paths already present, so redundancy is cheap.
|
||||
|
||||
### Contributors
|
||||
|
||||
- [@kveld9](https://github.com/kveld9) — click-to-toggle duration / remaining time in the player bar.
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal, ReplayGain UX feedback that drove the expandable badge, NixOS install guide, README polish.
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Install now** in the notification modal downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper re-approval, no manual DMG handling. The modal shows trust badges ("Notarized by Apple" + "Signature verified"), a 3-second restart countdown after install with a manual "Restart now" option, and hides redundant buttons during each download/install phase. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
|
||||
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Device Sync — fixed naming scheme + playlist folders** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The user-configurable filename template is gone. Every sync now writes files under a single, non-negotiable scheme:
|
||||
- Album / artist sources: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
|
||||
- Playlist sources: `Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}` plus a self-contained `.m3u8` that references sibling filenames.
|
||||
|
||||
**Why:** different OSes normalised separators and special characters differently, so the same library synced from macOS and then plugged into a Windows machine appeared "different" and re-downloaded every album. The fixed scheme ends that forever.
|
||||
|
||||
**Playlist folders instead of the album tree:** playlists used to be scattered across the album structure as `.m3u8` references. For playlists with 40 artists that meant 40 new folders on the stick. Now every playlist is one self-contained folder; the `.m3u8` sits inside it and references siblings, so you can copy the whole folder anywhere.
|
||||
|
||||
**Migration for existing sticks:** a "Reorganize existing files…" button on the Device Sync page reads the legacy template from the v1 manifest, computes per-track rename pairs, detects collisions, and executes atomic `fs::rename`s. Empty directories left behind are cleaned up automatically. Playlist tracks synced under the old scheme are left for the next sync to re-download into the new playlist folder, rather than being force-moved.
|
||||
|
||||
**Album-Artist fallback:** libraries without an albumArtist tag fall back to the track artist — "Unknown Artist" is only ever a last-resort placeholder.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
|
||||
|
||||
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
|
||||
|
||||
### Changed
|
||||
### Contributors
|
||||
|
||||
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
|
||||
- **PR [#205](https://github.com/Psychotoxical/psysonic/pull/205)** — Apple Music-style scrolling lyrics with spring-physics scroll, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#206](https://github.com/Psychotoxical/psysonic/pull/206)** — Golos Text + Unbounded fonts with Cyrillic support, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#207](https://github.com/Psychotoxical/psysonic/pull/207)** — WebKitGTK wheel scroll mode toggle, by [@cucadmuh](https://github.com/cucadmuh).
|
||||
|
||||
All three now credited in Settings → About.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# Psy Orbit
|
||||
|
||||
A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue.
|
||||
|
||||
No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [For users](#for-users)
|
||||
- [Starting a session (host)](#starting-a-session-host)
|
||||
- [Joining (guest)](#joining-guest)
|
||||
- [Suggesting tracks](#suggesting-tracks)
|
||||
- [Approvals](#approvals)
|
||||
- [Shared queue](#shared-queue)
|
||||
- [Session settings](#session-settings)
|
||||
- [Participants](#participants)
|
||||
- [Ending the session](#ending-the-session)
|
||||
- [Requirements & limits](#requirements--limits)
|
||||
- [How it works (technical)](#how-it-works-technical)
|
||||
- [Design goals](#design-goals)
|
||||
- [Playlists as transport](#playlists-as-transport)
|
||||
- [The host tick](#the-host-tick)
|
||||
- [The guest tick](#the-guest-tick)
|
||||
- [Data flow](#data-flow)
|
||||
- [State shape](#state-shape)
|
||||
- [Cleanup](#cleanup)
|
||||
- [Security & privacy](#security--privacy)
|
||||
- [Edge cases handled](#edge-cases-handled)
|
||||
- [Code map](#code-map)
|
||||
|
||||
---
|
||||
|
||||
## For users
|
||||
|
||||
### Starting a session (host)
|
||||
|
||||
Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with:
|
||||
|
||||
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
|
||||
- **Max guests** — cap on concurrent participants (1–32). You don't count.
|
||||
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
|
||||
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
|
||||
|
||||
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
|
||||
|
||||
### Joining (guest)
|
||||
|
||||
Two equivalent paths:
|
||||
|
||||
1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join.
|
||||
2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join.
|
||||
|
||||
Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as.
|
||||
|
||||
### Suggesting tracks
|
||||
|
||||
Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search):
|
||||
|
||||
- **Double-click** a row → adds just that track.
|
||||
- **Right-click → "Add to Orbit session"** — same effect, via context menu.
|
||||
- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone.
|
||||
|
||||
Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it.
|
||||
|
||||
### Approvals
|
||||
|
||||
By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides.
|
||||
|
||||
The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons:
|
||||
|
||||
- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick.
|
||||
- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip.
|
||||
|
||||
Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed.
|
||||
|
||||
### Shared queue
|
||||
|
||||
Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that:
|
||||
|
||||
- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range.
|
||||
- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions.
|
||||
|
||||
When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list.
|
||||
|
||||
### Session settings
|
||||
|
||||
Open via the gear icon in the session bar (host only):
|
||||
|
||||
- **Auto-approve suggestions** — on/off. Default off.
|
||||
- **Automatic reshuffle** — on/off. Periodically Fisher–Yates-shuffles the upcoming queue.
|
||||
- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off.
|
||||
- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer.
|
||||
|
||||
### Participants
|
||||
|
||||
Click the participant count in the session bar. Opens a popover:
|
||||
|
||||
- Host row at the top with a crown icon.
|
||||
- Each connected guest with a user icon, username, and join timestamp.
|
||||
- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing.
|
||||
|
||||
Guests see the same list but read-only — no action buttons.
|
||||
|
||||
### Ending the session
|
||||
|
||||
- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically.
|
||||
- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else.
|
||||
- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal.
|
||||
- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat).
|
||||
|
||||
### The help modal
|
||||
|
||||
Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand).
|
||||
|
||||
---
|
||||
|
||||
## Requirements & limits
|
||||
|
||||
- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there.
|
||||
- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username.
|
||||
- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address.
|
||||
- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible.
|
||||
- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue.
|
||||
|
||||
---
|
||||
|
||||
## How it works (technical)
|
||||
|
||||
### Design goals
|
||||
|
||||
1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own.
|
||||
2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session.
|
||||
3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch.
|
||||
4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election.
|
||||
|
||||
### Playlists as transport
|
||||
|
||||
Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`):
|
||||
|
||||
| Playlist | Who owns it | What's in it |
|
||||
|---|---|---|
|
||||
| `__psyorbit_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
|
||||
| `__psyorbit_<sid>_from_<user>__` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. |
|
||||
|
||||
All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active.
|
||||
|
||||
### The host tick
|
||||
|
||||
Fired every 2.5 s from `useOrbitHost`:
|
||||
|
||||
1. **Sweep all outboxes.** List every `__psyorbit_<sid>_from_<user>__` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment.
|
||||
2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`.
|
||||
3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's.
|
||||
4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave.
|
||||
5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, Fisher–Yates-reorder the upcoming play queue + rewrite `state.lastShuffle`.
|
||||
6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob.
|
||||
7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`.
|
||||
|
||||
Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically.
|
||||
|
||||
### The guest tick
|
||||
|
||||
Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s:
|
||||
|
||||
1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState.
|
||||
2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout.
|
||||
3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal.
|
||||
4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically).
|
||||
5. **Auto-sync to host.** Three cases:
|
||||
- Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own).
|
||||
- Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick.
|
||||
- First tick after join → mirror unconditionally (initial sync).
|
||||
6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Host (per tick) Navidrome Guest (per tick)
|
||||
──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
player.currentTrack
|
||||
+ position
|
||||
│
|
||||
▼
|
||||
snapshotPlayerPatch ──► writeOrbitState ─┐
|
||||
│
|
||||
┌─session playlist─┐
|
||||
│ comment = JSON │ ◄─readOrbitState
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
parse OrbitState
|
||||
│
|
||||
▼
|
||||
syncToHost:
|
||||
• getSong
|
||||
• playTrack
|
||||
• seek
|
||||
• resume/pause
|
||||
|
||||
|
||||
Guest suggests track Y
|
||||
┌────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
▼ │
|
||||
suggestOrbitTrack
|
||||
┌──guest outbox──┐
|
||||
│ track list = Y │ ◄─updatePlaylist
|
||||
└────────────────┘
|
||||
│
|
||||
│
|
||||
Host: sweepGuestOutboxes ◄──────────┘
|
||||
│
|
||||
▼
|
||||
applyOutboxSnapshotsToState
|
||||
(queue += Y, participants refreshed)
|
||||
│
|
||||
▼ (if auto-approve)
|
||||
mergeNewSuggestionsIntoQueue
|
||||
│
|
||||
▼
|
||||
player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles
|
||||
pending list
|
||||
```
|
||||
|
||||
### State shape
|
||||
|
||||
All relevant types in `src/api/orbit.ts`:
|
||||
|
||||
```ts
|
||||
interface OrbitState {
|
||||
v: 3;
|
||||
sid: string; // 8 hex chars
|
||||
host: string; // navidrome username
|
||||
name: string; // human-readable session name
|
||||
started: number; // ms since epoch
|
||||
maxUsers: number;
|
||||
currentTrack: OrbitQueueItem | null;
|
||||
isPlaying: boolean;
|
||||
positionMs: number;
|
||||
positionAt: number; // wall-clock ms of the last snapshot
|
||||
queue: OrbitQueueItem[]; // suggestion history
|
||||
playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming
|
||||
playQueueTotal: number;
|
||||
participants: OrbitParticipant[];
|
||||
kicked: string[];
|
||||
removed: { user: string; at: number }[];
|
||||
lastShuffle: number;
|
||||
settings: {
|
||||
autoApprove: boolean;
|
||||
autoShuffle: boolean;
|
||||
shuffleIntervalMin: 1 | 5 | 10 | 15 | 30;
|
||||
};
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
interface OrbitQueueItem {
|
||||
trackId: string;
|
||||
addedBy: string; // navidrome username
|
||||
addedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry.
|
||||
|
||||
### Cleanup
|
||||
|
||||
Three layers of defense against orphaned playlists:
|
||||
|
||||
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
|
||||
2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
|
||||
3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
|
||||
|
||||
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer.
|
||||
- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not.
|
||||
- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server.
|
||||
- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox.
|
||||
- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases handled
|
||||
|
||||
- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock).
|
||||
- **Host offline 15 s – 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally.
|
||||
- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal.
|
||||
- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync.
|
||||
- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track".
|
||||
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
|
||||
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
|
||||
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
|
||||
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
|
||||
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
|
||||
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
|
||||
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
|
||||
- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal.
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
|
||||
### State and types
|
||||
- `src/api/orbit.ts` — `OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`.
|
||||
- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`.
|
||||
|
||||
### Lifecycle
|
||||
- `src/utils/orbit.ts` — `startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
|
||||
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
|
||||
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
|
||||
|
||||
### Hooks
|
||||
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
|
||||
- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection.
|
||||
- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists.
|
||||
|
||||
### UI — session bar and popovers
|
||||
- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons.
|
||||
- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle).
|
||||
- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button.
|
||||
- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions).
|
||||
|
||||
### UI — modals
|
||||
- `src/components/OrbitStartModal.tsx` — session creation wizard.
|
||||
- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join.
|
||||
- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining.
|
||||
- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice.
|
||||
- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable).
|
||||
- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help).
|
||||
|
||||
### UI — queue views
|
||||
- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge).
|
||||
- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming).
|
||||
- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline.
|
||||
|
||||
### Supporting
|
||||
- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate.
|
||||
- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state.
|
||||
- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.
|
||||
@@ -20,6 +20,9 @@ All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm cred
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
|
||||
### YouLyPlus (Lyrics)
|
||||
If YouLyPlus mode is selected in Settings → Lyrics, Psysonic sends the track title, artist, album, duration, and ISRC (when available) to a community-operated [lyricsplus](https://github.com/ibratabian17/lyricsplus) backend to fetch word-synced karaoke lyrics. No account is required. Requests are routed through a list of public mirrors; the data they receive is limited to the search query above. This feature is disabled by default.
|
||||
|
||||
### NetEase Cloud Music (Lyrics)
|
||||
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
|
||||
|
||||
|
||||
@@ -1,164 +1,196 @@
|
||||
<div align="center">
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
|
||||
|
||||
<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: 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>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
---
|
||||
# Psysonic
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/AMnDRErm4u">
|
||||
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
|
||||
</a>
|
||||
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||
|
||||
## The Ultimate Desktop Client for Self-Hosted Music Libraries
|
||||
|
||||
**Fast. Beautiful. Native. Feature-packed.**
|
||||
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**No telemetry • Native performance • Massive feature set • Community driven**
|
||||
|
||||
</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) 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.
|
||||
|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
---
|
||||
|
||||
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
|
||||
- ⚡ **Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
|
||||
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
|
||||
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
|
||||
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
|
||||
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
|
||||
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
|
||||
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (1–5 stars), and multi-select actions.
|
||||
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
|
||||
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
|
||||
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
|
||||
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
|
||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
> [!WARNING]
|
||||
> Psysonic is under heavy active development. Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
## 🗺️ Roadmap
|
||||
## Server Compatibility
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
**Psysonic is optimized first and foremost for Navidrome.**
|
||||
|
||||
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
|
||||
|
||||
## Why Psysonic?
|
||||
|
||||
Most Subsonic clients feel like web wrappers.
|
||||
|
||||
**Psysonic does not.**
|
||||
|
||||
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
|
||||
|
||||
If you host your own music, this is what the premium experience should feel like.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
# Core Features
|
||||
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
## Playback Engine
|
||||
|
||||
### 🐧 Linux
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* Smart Loudness Normalization
|
||||
* Infinite Queue
|
||||
* Smart Radio sessions
|
||||
* High responsiveness with low memory usage
|
||||
|
||||
## Audio Tools
|
||||
|
||||
* 10-band Equalizer
|
||||
* Presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device optimization
|
||||
|
||||
## Library Power
|
||||
|
||||
* Lightning-fast search
|
||||
* Albums / Artists / Tracks / Genres
|
||||
* Ratings system
|
||||
* Multi-select bulk actions
|
||||
* Drag & drop playlist management
|
||||
* Huge library friendly
|
||||
|
||||
## Lyrics & Discovery
|
||||
|
||||
* Synced lyrics with seek support
|
||||
* Auto-scroll sidebar lyrics
|
||||
* Fullscreen lyric mode
|
||||
* Last.fm scrobbling
|
||||
* Similar artists / love tracks / stats
|
||||
|
||||
## Personalization
|
||||
|
||||
* Huge theme collection
|
||||
* Catppuccin / Nord inspired styles
|
||||
* Glassmorphism effects
|
||||
* Font customization
|
||||
* Zoom controls
|
||||
* Keybind remapping
|
||||
* Theme Scheduler (day/night auto switch)
|
||||
|
||||
## Power User Extras
|
||||
|
||||
* CLI controls
|
||||
* USB / portable sync
|
||||
* Backup & restore settings
|
||||
* In-app auto updater
|
||||
* LAN / remote auto switching
|
||||
|
||||
---
|
||||
|
||||
# Orbit (Upcoming)
|
||||
|
||||
## Listen Together. In Sync. Soon.
|
||||
|
||||
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
|
||||
|
||||
* Host-controlled playback
|
||||
* Join via link
|
||||
* Shared listening sessions
|
||||
* Guest song suggestions
|
||||
* Real-time queue interaction
|
||||
|
||||
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
|
||||
|
||||
---
|
||||
|
||||
# Platforms
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native Installer *(certificate pending)* |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
Supports **8 languages** and growing.
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
## Linux
|
||||
|
||||
**Quick Install (Recommended):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
**Manual Installation:**
|
||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
||||
## Windows
|
||||
|
||||
### 🍎 macOS
|
||||
Download the latest installer from Releases.
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||
|
||||
> [!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
|
||||
> ```
|
||||
## macOS
|
||||
|
||||
### 🪟 Windows
|
||||
Download the signed DMG from Releases.
|
||||
|
||||
- **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
|
||||
|
||||
1. Download and install Psysonic.
|
||||
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
|
||||
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
|
||||
4. Enjoy your music!
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
If you want to build Psysonic from source or contribute to the project:
|
||||
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
||||
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
|
||||
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
|
||||
- macOS: `brew install cmake`
|
||||
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
||||
|
||||
### Setup
|
||||
# Development
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
|
||||
# Install node dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
# Build for production
|
||||
Build release:
|
||||
|
||||
```bash
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
---
|
||||
|
||||
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
|
||||
# Privacy First
|
||||
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
* No telemetry
|
||||
* No spyware nonsense
|
||||
* No analytics harvesting
|
||||
* Your library stays yours
|
||||
|
||||
## 📄 License
|
||||
---
|
||||
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
# Community
|
||||
|
||||
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.
|
||||
Join Discord, report bugs, suggest features, share themes, shape the future.
|
||||
|
||||
## 🔒 Privacy
|
||||
---
|
||||
|
||||
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
|
||||
# License
|
||||
|
||||
GNU GPL v3.0
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Stop using boring music clients.
|
||||
|
||||
## Use Psysonic.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Release Process (Strict SOP)
|
||||
|
||||
This document defines the **only allowed** release workflow for this repository.
|
||||
All maintainers should follow it exactly.
|
||||
|
||||
## 1) Branch roles
|
||||
|
||||
- `main`:
|
||||
- primary development branch
|
||||
- all regular feature/fix work lands here via PR
|
||||
- should usually carry a development version (for example `X.Y.Z-dev`)
|
||||
- `next`:
|
||||
- release-candidate (RC) stabilization branch
|
||||
- receives promoted changes from `main`
|
||||
- receives RC-only fixes during freeze
|
||||
- `release`:
|
||||
- stable release branch
|
||||
- only receives promoted commits from `next`
|
||||
|
||||
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
|
||||
|
||||
## 2) Versioning rules (mandatory)
|
||||
|
||||
Version is authoritative in `package.json` and `package-lock.json`.
|
||||
|
||||
- `main` version format: `X.Y.Z-dev`
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
- `release` version format: `X.Y.Z`
|
||||
|
||||
Rules:
|
||||
|
||||
1. Never edit versions manually in random commits.
|
||||
2. Version transitions must happen through the defined promotion workflows.
|
||||
3. Tags must match package version:
|
||||
- RC: `app-vX.Y.Z-rc.N`
|
||||
- Stable: `app-vX.Y.Z`
|
||||
|
||||
## 3) Standard release flow
|
||||
|
||||
### Step A: Prepare in `main`
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
- resets `next` to `main` snapshot
|
||||
- auto-bump package version in `next` to next `-rc.N`
|
||||
- commit and push version bump
|
||||
3. Push on `next` triggers **Next Channel** workflow:
|
||||
- build/publish RC artifacts for all platforms
|
||||
- run Nix verification path
|
||||
|
||||
### Step C: Stabilize RC
|
||||
|
||||
1. Test RC artifacts.
|
||||
2. If fixes are needed, follow Section 5 (RC fix policy).
|
||||
3. Repeat Step B as needed until release candidate is accepted.
|
||||
|
||||
### Step D: Promote to stable (`release`)
|
||||
|
||||
1. Run workflow: **Promote next to release**.
|
||||
2. Workflow behavior:
|
||||
- resets `release` to `next` snapshot
|
||||
- finalize version from `-rc.N` to `X.Y.Z`
|
||||
- commit and push finalized version
|
||||
3. Push on `release` triggers **Release Channel** workflow:
|
||||
- stable artifact publish
|
||||
- Nix verification
|
||||
- opens PR to bump `main` to next minor `-dev`
|
||||
|
||||
### Step E: Move `main` forward
|
||||
|
||||
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
|
||||
2. Confirm `main` now uses `X.(Y+1).0-dev`.
|
||||
3. Update AUR package metadata for the same stable version:
|
||||
- bump `pkgver` in `packages/aur/PKGBUILD`
|
||||
- regenerate `packages/aur/.SRCINFO`
|
||||
- publish/update in AUR remote
|
||||
|
||||
## 4) Freeze policy (RC stabilization window)
|
||||
|
||||
When RC freeze starts:
|
||||
|
||||
- Do **not** run `Promote main to next` automatically or casually.
|
||||
- Only approved release manager(s) may run promotion workflows.
|
||||
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
|
||||
- New features remain in `main` and wait for next cycle.
|
||||
|
||||
Freeze ends after `next -> release` promotion is complete.
|
||||
|
||||
## 5) RC fix policy (strict backport/forward-port rules)
|
||||
|
||||
If a bug is discovered during RC stabilization:
|
||||
|
||||
1. Create dedicated fix branch from `next`:
|
||||
- example: `fix/rc-crash-login`
|
||||
2. Open PR: `fix/rc-crash-login -> next`
|
||||
3. After merge to `next`, create dedicated backport branch from `main`:
|
||||
- example: `fix/backport-rc-crash-login-main`
|
||||
4. Cherry-pick (or re-apply) same fix.
|
||||
5. Open PR: `fix/backport-rc-crash-login-main -> main`
|
||||
6. Merge this `main` backport PR before the next `Promote main to next` run.
|
||||
|
||||
This is mandatory. RC-only fixes may not stay only in `next`.
|
||||
|
||||
Alternative allowed order:
|
||||
|
||||
- implement first in `main`, then promote `main -> next`.
|
||||
|
||||
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
|
||||
|
||||
## 6) Post-release critical hotfix policy (default path)
|
||||
|
||||
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
|
||||
|
||||
- next stable target is always `X.Y.(Z+1)`
|
||||
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
|
||||
- final stable tag: `app-vX.Y.(Z+1)`
|
||||
|
||||
Never re-use or overwrite `X.Y.Z` tags/releases.
|
||||
|
||||
### Case A: `next` is not yet used for the next minor
|
||||
|
||||
This case is uncommon in this repository but allowed.
|
||||
|
||||
1. Create hotfix branch from `release`.
|
||||
2. Implement fix and open PR to `release`.
|
||||
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
|
||||
4. Promote `next -> release` for final `X.Y.(Z+1)`.
|
||||
5. Backport fix to `main` via dedicated PR (mandatory).
|
||||
|
||||
### Case B (default): `next` already tracks next minor
|
||||
|
||||
This is the expected real-world case.
|
||||
|
||||
Assume:
|
||||
|
||||
- `release` is `1.9.0`
|
||||
- `main`/`next` already moved to `1.10.0-*`
|
||||
- critical bug requires `1.9.1`
|
||||
|
||||
Required steps:
|
||||
|
||||
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
|
||||
2. Create hotfix branch from `release` (`1.9.0` baseline).
|
||||
3. Implement fix and merge into `release` branch via PR.
|
||||
4. Temporarily align `next` to the hotfix patch line for RC publication.
|
||||
5. Publish hotfix RC(s): `1.9.1-rc.N`.
|
||||
6. Promote `next -> release` to finalize `1.9.1`.
|
||||
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
|
||||
8. Restore `next` back to the normal next-minor line from `main`.
|
||||
9. Announce end of hotfix override and resume normal RC cycle.
|
||||
|
||||
Hard rule: no feature work may be merged into `next` during hotfix override.
|
||||
|
||||
## 7) Idempotency and rerun behavior
|
||||
|
||||
Manual workflow reruns should be safe:
|
||||
|
||||
- rerunning **Promote main to next**:
|
||||
- no change if no new commits
|
||||
- version bump occurs only when needed for next RC number
|
||||
- rerunning **Promote next to release**:
|
||||
- no change if release already matches next
|
||||
- no extra version increment beyond `X.Y.Z`
|
||||
- rerunning release publish:
|
||||
- main dev bump step should no-op when `main` already has target dev version
|
||||
|
||||
Rerun is allowed for recovery, but must be announced in release channel/chat.
|
||||
|
||||
## 8) Hard rules and prohibitions
|
||||
|
||||
Do:
|
||||
|
||||
- use PRs for all code changes
|
||||
- keep channel promotions deterministic and force-push only through approved promotion workflows
|
||||
- require green CI before promotions
|
||||
- document exceptions in PR description
|
||||
|
||||
Do not:
|
||||
|
||||
- manually retag or overwrite release tags
|
||||
- manually edit `package.json` version outside defined release flow
|
||||
- merge feature PRs into `next` during freeze
|
||||
- skip the `next/release -> main` backport for RC fixes or hotfixes
|
||||
- force-push `next` or `release` manually outside promotion workflows
|
||||
|
||||
## 9) Incident handling
|
||||
|
||||
If an incorrect promotion happened:
|
||||
|
||||
1. Stop further promotions immediately.
|
||||
2. Announce incident and current branch SHAs.
|
||||
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
|
||||
4. Re-run affected workflows only after corrective PRs are merged.
|
||||
|
||||
## 10) Operator checklist (quick)
|
||||
|
||||
Before `main -> next`:
|
||||
|
||||
- [ ] `main` CI green
|
||||
- [ ] freeze status known
|
||||
- [ ] release manager approval
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `next`
|
||||
|
||||
Before `next -> release`:
|
||||
|
||||
- [ ] RC validation complete
|
||||
- [ ] all RC fixes merged to `next`
|
||||
- [ ] corresponding backports to `main` completed or queued with owners
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `release`
|
||||
|
||||
After stable release:
|
||||
|
||||
- [ ] verify stable artifacts exist
|
||||
- [ ] merge auto PR for next `-dev` bump in `main`
|
||||
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
|
||||
- [ ] announce cycle close
|
||||
|
||||
For post-release hotfix:
|
||||
|
||||
- [ ] patch target decided: `X.Y.(Z+1)`
|
||||
- [ ] hotfix override for `next` announced
|
||||
- [ ] fix merged to release patch line
|
||||
- [ ] hotfix backport PR to `main` merged
|
||||
- [ ] `next` restored to normal next-minor line
|
||||
|
||||
Nix note:
|
||||
|
||||
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
|
||||
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
|
||||
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
|
||||
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.
|
||||
+15
-2
@@ -60,7 +60,7 @@ _psysonic_server_ids() {
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --player completions
|
||||
--help --version --info --json --quiet --logs --player completions
|
||||
}
|
||||
|
||||
integer i pidx=0
|
||||
@@ -68,12 +68,25 @@ for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --player ]] && pidx=i
|
||||
done
|
||||
|
||||
if [[ ${words[CURRENT-1]} == --tail ]]; then
|
||||
_message -e descriptions 'number of lines'
|
||||
return
|
||||
fi
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
|
||||
compadd help bash zsh
|
||||
return
|
||||
fi
|
||||
_psysonic_globals
|
||||
if (( has_logs )); then
|
||||
compadd -- --tail -f --follow
|
||||
else
|
||||
_psysonic_globals
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
@@ -54,6 +54,14 @@ _psysonic_snapshot_json() {
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local prev=""
|
||||
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ $prev == --tail ]]; then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
return
|
||||
fi
|
||||
|
||||
local i pidx=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
@@ -61,11 +69,19 @@ _psysonic_complete() {
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
|
||||
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
|
||||
return
|
||||
fi
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
|
||||
if (( has_logs )); then
|
||||
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
|
||||
else
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776169885,
|
||||
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
|
||||
"lastModified": 1776877367,
|
||||
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
|
||||
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
|
||||
"npmDepsHash": "sha256-PYqvngGz/yreDiQxAEIhD5nuEPezAELj/zaEpJ7c1f4="
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Installing Psysonic on NixOS (flake)
|
||||
|
||||
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Flakes** enabled (e.g. in `configuration.nix`):
|
||||
|
||||
```nix
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
```
|
||||
|
||||
## Binary cache (Cachix)
|
||||
|
||||
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
|
||||
|
||||
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
|
||||
- **Substituter URL:** `https://psysonic.cachix.org`
|
||||
- **Public key** (trust this only if it matches what you expect from the cache owners):
|
||||
|
||||
```text
|
||||
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
|
||||
```
|
||||
|
||||
### NixOS (`configuration.nix` or a flake module)
|
||||
|
||||
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"https://psysonic.cachix.org"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
|
||||
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
|
||||
|
||||
## Install on NixOS (flake configuration)
|
||||
|
||||
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
|
||||
|
||||
### Example: top-level `flake.nix` + `nixosConfigurations`
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
psysonic.url = "github:Psychotoxical/psysonic";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs: let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{
|
||||
environment.systemPackages = [
|
||||
inputs.psysonic.packages.${system}.psysonic
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# …
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
### Pinning a revision or tag
|
||||
|
||||
Follow **`main`** (above) to track the moving branch, or pin for reproducibility:
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.34.13"; # example: release tag
|
||||
```
|
||||
|
||||
Use a tag or commit SHA that exists on GitHub; the release workflow keeps **`flake.lock`** and **`nix/upstream-sources.json`** (`npmDepsHash`) in sync on tagged releases.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
- **NixOS flake host**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#my-host
|
||||
```
|
||||
|
||||
- **Home Manager** (if used separately)
|
||||
|
||||
```bash
|
||||
home-manager switch --flake .#my-user@my-host
|
||||
```
|
||||
|
||||
## Home Manager
|
||||
|
||||
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
|
||||
|
||||
```nix
|
||||
home.packages = [
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
## Troubleshooting (Linux / WebKit)
|
||||
|
||||
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
|
||||
|
||||
## More detail in-repo
|
||||
|
||||
- **`flake.nix`** — package outputs, `devShell`, supported systems (see comments there for `nix build` / `nix develop`).
|
||||
- **`nix/psysonic.nix`** — how the app is built from this source tree.
|
||||
- **`.github/workflows/release.yml`** — `verify-nix` job: refreshes lock/npm hash and pushes store paths to Cachix on release tags.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.44.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.44.0-dev",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -2671,9 +2671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.16",
|
||||
"version": "1.44.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.12
|
||||
pkgver=1.43.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -20,7 +20,7 @@ makedepends=(
|
||||
'nasm'
|
||||
'cmake'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
|
||||
* Used after npm version in promote workflows so bundle names match release semver.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const version = require(path.join(root, 'package.json')).version;
|
||||
if (!version || typeof version !== 'string') {
|
||||
console.error('package.json version missing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cargoPath = path.join(root, 'src-tauri', 'Cargo.toml');
|
||||
let cargo = fs.readFileSync(cargoPath, 'utf8');
|
||||
if (!/^version = "[^"]*"$/m.test(cargo)) {
|
||||
console.error('Cargo.toml: expected a package-level line: version = "..."');
|
||||
process.exit(1);
|
||||
}
|
||||
cargo = cargo.replace(/^version = "[^"]*"$/m, `version = "${version}"`);
|
||||
fs.writeFileSync(cargoPath, cargo);
|
||||
console.log(`Cargo.toml -> ${version}`);
|
||||
|
||||
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.version = version;
|
||||
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
|
||||
console.log(`tauri.conf.json -> ${version}`);
|
||||
Generated
+94
-13
@@ -944,6 +944,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_frame"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -1169,6 +1178,18 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ebur128"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1302,6 +1323,18 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
@@ -1908,6 +1941,15 @@ version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2548,6 +2590,17 @@ dependencies = [
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
@@ -3115,9 +3168,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
version = "5.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -3653,10 +3706,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.16"
|
||||
version = "1.44.0-rc1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"libc",
|
||||
@@ -3665,6 +3719,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
@@ -4116,6 +4171,20 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -4236,9 +4305,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5971,9 +6040,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
@@ -6115,6 +6184,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -6186,11 +6261,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6199,7 +6274,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6339,9 +6414,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
@@ -7139,6 +7214,12 @@ dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.16"
|
||||
version = "1.44.0-dev"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -49,6 +49,8 @@ lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Psysonic",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "mini"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
@@ -36,6 +36,7 @@
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
|
||||
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
|
||||
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
|
||||
}
|
||||
BufferSize::Default => {
|
||||
// These values together represent a moderate latency and wakeup interval.
|
||||
// Without them, we are at the mercy of the device
|
||||
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?;
|
||||
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?;
|
||||
// Larger buffer than upstream cpal default: after Symphonia seeks or CPU
|
||||
// spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
|
||||
// ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
|
||||
hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?;
|
||||
hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ use std::fmt;
|
||||
use std::mem;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr::null;
|
||||
use std::rc::Rc;
|
||||
use std::slice;
|
||||
use std::sync::mpsc::{channel, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -342,7 +342,7 @@ impl Device {
|
||||
/// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it.
|
||||
fn ensure_future_audio_client(
|
||||
&self,
|
||||
) -> Result<MutexGuard<Option<IAudioClientWrapper>>, windows::core::Error> {
|
||||
) -> Result<MutexGuard<'_, Option<IAudioClientWrapper>>, windows::core::Error> {
|
||||
let mut lock = self.future_audio_client.lock().unwrap();
|
||||
if lock.is_some() {
|
||||
return Ok(lock);
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ebur128::{EbuR128, Mode as Ebur128Mode};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use tauri::Manager;
|
||||
|
||||
pub const WAVEFORM_ALGO_VERSION: i64 = 4;
|
||||
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
|
||||
|
||||
/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin).
|
||||
pub fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool {
|
||||
if bin_count <= 0 {
|
||||
return false;
|
||||
}
|
||||
let n = bin_count as usize;
|
||||
bins.len() == n.saturating_mul(2)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackKey {
|
||||
pub track_id: String,
|
||||
pub md5_16kb: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaveformEntry {
|
||||
pub bins: Vec<u8>,
|
||||
pub bin_count: i64,
|
||||
pub is_partial: bool,
|
||||
pub known_until_sec: f64,
|
||||
pub duration_sec: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoudnessEntry {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoudnessSnapshot {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub struct AnalysisCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
/// Ranged HTTP seeding uses `stream:<subsonicId>` (see `playback_identity`); backfill
|
||||
/// and IPC often use the bare `<subsonicId>`. Rows may exist under either key.
|
||||
fn track_id_cache_variants(id: &str) -> Vec<String> {
|
||||
let mut out = vec![id.to_string()];
|
||||
if let Some(bare) = id.strip_prefix("stream:") {
|
||||
if !bare.is_empty() {
|
||||
out.push(bare.to_string());
|
||||
}
|
||||
} else {
|
||||
out.push(format!("stream:{id}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl AnalysisCache {
|
||||
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
|
||||
let db_path = analysis_db_path(app)?;
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&conn).map_err(|e| e.to_string())?;
|
||||
migrate_schema(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
/// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant).
|
||||
pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result<u64, String> {
|
||||
if track_id.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let mut total: u64 = 0;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let n = conn
|
||||
.execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid])
|
||||
.map_err(|e| e.to_string())?;
|
||||
total = total.saturating_add(n as u64);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Remove all cached waveform rows across all tracks/variants.
|
||||
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let n = conn
|
||||
.execute("DELETE FROM waveform_cache", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
|
||||
let now = now_unix_ts();
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO analysis_track (
|
||||
track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
waveform_algo_version = excluded.waveform_algo_version,
|
||||
loudness_algo_version = excluded.loudness_algo_version,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
status,
|
||||
WAVEFORM_ALGO_VERSION,
|
||||
LOUDNESS_ALGO_VERSION,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO waveform_cache (
|
||||
track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
|
||||
bins = excluded.bins,
|
||||
bin_count = excluded.bin_count,
|
||||
is_partial = excluded.is_partial,
|
||||
known_until_sec = excluded.known_until_sec,
|
||||
duration_sec = excluded.duration_sec,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
entry.bins,
|
||||
entry.bin_count,
|
||||
if entry.is_partial { 1 } else { 0 },
|
||||
entry.known_until_sec,
|
||||
entry.duration_sec,
|
||||
entry.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO loudness_cache (
|
||||
track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET
|
||||
integrated_lufs = excluded.integrated_lufs,
|
||||
true_peak = excluded.true_peak,
|
||||
recommended_gain_db = excluded.recommended_gain_db,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
entry.integrated_lufs,
|
||||
entry.true_peak,
|
||||
entry.recommended_gain_db,
|
||||
entry.target_lufs,
|
||||
entry.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
r#"
|
||||
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
|
||||
FROM waveform_cache w
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = w.track_id
|
||||
AND a.md5_16kb = w.md5_16kb
|
||||
WHERE w.track_id = ?1
|
||||
AND w.md5_16kb = ?2
|
||||
AND a.waveform_algo_version = ?3
|
||||
"#,
|
||||
params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(WaveformEntry {
|
||||
bins: row.get(0)?,
|
||||
bin_count: row.get(1)?,
|
||||
is_partial: row.get::<_, i64>(2)? != 0,
|
||||
known_until_sec: row.get(3)?,
|
||||
duration_sec: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
|
||||
}
|
||||
|
||||
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
|
||||
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
|
||||
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM loudness_cache l
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = l.track_id
|
||||
AND a.md5_16kb = l.md5_16kb
|
||||
WHERE l.track_id = ?1
|
||||
AND l.md5_16kb = ?2
|
||||
AND a.loudness_algo_version = ?3
|
||||
)
|
||||
"#,
|
||||
params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(exists != 0)
|
||||
}
|
||||
|
||||
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
const SQL: &str = r#"
|
||||
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
|
||||
FROM waveform_cache w
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = w.track_id
|
||||
AND a.md5_16kb = w.md5_16kb
|
||||
WHERE w.track_id = ?1
|
||||
AND a.waveform_algo_version = ?2
|
||||
ORDER BY w.updated_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let row = conn
|
||||
.query_row(
|
||||
SQL,
|
||||
params![tid, WAVEFORM_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(WaveformEntry {
|
||||
bins: row.get(0)?,
|
||||
bin_count: row.get(1)?,
|
||||
is_partial: row.get::<_, i64>(2)? != 0,
|
||||
known_until_sec: row.get(3)?,
|
||||
duration_sec: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(e) = row {
|
||||
if waveform_cache_blob_len_ok(&e.bins, e.bin_count) {
|
||||
return Ok(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
const SQL: &str = r#"
|
||||
SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at
|
||||
FROM loudness_cache l
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = l.track_id
|
||||
AND a.md5_16kb = l.md5_16kb
|
||||
WHERE l.track_id = ?1
|
||||
AND a.loudness_algo_version = ?2
|
||||
ORDER BY l.updated_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let row = conn
|
||||
.query_row(
|
||||
SQL,
|
||||
params![tid, LOUDNESS_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(LoudnessSnapshot {
|
||||
integrated_lufs: row.get(0)?,
|
||||
true_peak: row.get(1)?,
|
||||
recommended_gain_db: row.get(2)?,
|
||||
target_lufs: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if row.is_some() {
|
||||
return Ok(row);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
|
||||
let mut recommended_gain_db = target_lufs - integrated_lufs;
|
||||
if true_peak > 0.0 {
|
||||
let true_peak_dbtp = 20.0 * true_peak.log10();
|
||||
let max_gain_db = -1.0 - true_peak_dbtp;
|
||||
if recommended_gain_db > max_gain_db {
|
||||
recommended_gain_db = max_gain_db;
|
||||
}
|
||||
}
|
||||
recommended_gain_db.clamp(-24.0, 24.0)
|
||||
}
|
||||
|
||||
/// Result of [`seed_from_bytes_execute`] / CPU seed queue: callers use it to avoid redundant UI events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SeedFromBytesOutcome {
|
||||
/// Wrote waveform (and loudness when PCM decode succeeded).
|
||||
Upserted,
|
||||
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
|
||||
SkippedWaveformCacheHit,
|
||||
/// `AnalysisCache` was not registered on the app handle.
|
||||
SkippedNoAnalysisCache,
|
||||
}
|
||||
|
||||
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
|
||||
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
|
||||
pub fn seed_from_bytes_execute(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<SeedFromBytesOutcome, String> {
|
||||
let started = Instant::now();
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
|
||||
track_id,
|
||||
bytes.len()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
};
|
||||
let key = TrackKey {
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_first_16kb(bytes),
|
||||
};
|
||||
if let Some(existing) = cache.get_waveform(&key)? {
|
||||
if !existing.bins.is_empty() {
|
||||
if cache.loudness_row_exists_for_key(&key)? {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
|
||||
track_id,
|
||||
key.md5_16kb,
|
||||
existing.bins.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
|
||||
track_id,
|
||||
key.md5_16kb
|
||||
);
|
||||
}
|
||||
}
|
||||
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis start track_id={} input_mib={:.2} md5_16kb={}",
|
||||
track_id,
|
||||
mib,
|
||||
key.md5_16kb
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis work: Symphonia decodes the entire buffer twice (frame timeline, then PCM peak bins), then EBU R128 integrated loudness + true-peak when that succeeds — CPU-bound; large lossless files often take minutes"
|
||||
);
|
||||
|
||||
let build = (|| -> Result<(bool, usize), String> {
|
||||
cache.touch_track_status(&key, "queued")?;
|
||||
|
||||
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
|
||||
(
|
||||
bins,
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
None => (derive_waveform_bins(bytes, 500), None, false),
|
||||
};
|
||||
let bins_len = wf_bins.len();
|
||||
let waveform = WaveformEntry {
|
||||
bins: wf_bins,
|
||||
bin_count: 500,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 0.0,
|
||||
updated_at: now_unix_ts(),
|
||||
};
|
||||
cache.upsert_waveform(&key, &waveform)?;
|
||||
|
||||
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
|
||||
let loudness = LoudnessEntry {
|
||||
integrated_lufs,
|
||||
true_peak,
|
||||
recommended_gain_db,
|
||||
target_lufs,
|
||||
updated_at: now_unix_ts(),
|
||||
};
|
||||
cache.upsert_loudness(&key, &loudness)?;
|
||||
}
|
||||
|
||||
cache.touch_track_status(&key, "ready")?;
|
||||
Ok((used_pcm_decode, bins_len))
|
||||
})();
|
||||
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
match &build {
|
||||
Ok((used_pcm_decode, bins_len)) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis done track_id={} elapsed_ms={} decode_path={} bins_len={} ebu_loudness_cached={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
if *used_pcm_decode {
|
||||
"pcm_ebur128"
|
||||
} else {
|
||||
"byte_envelope_no_ebu"
|
||||
},
|
||||
bins_len,
|
||||
*used_pcm_decode
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match build {
|
||||
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ts() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn md5_first_16kb(bytes: &[u8]) -> String {
|
||||
let n = bytes.len().min(16 * 1024);
|
||||
format!("{:x}", md5::compute(&bytes[..n]))
|
||||
}
|
||||
|
||||
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
|
||||
if bin_count == 0 || bytes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut peak_half = vec![0u8; bin_count];
|
||||
for (i, slot) in peak_half.iter_mut().enumerate() {
|
||||
let start = i * bytes.len() / bin_count;
|
||||
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
|
||||
let mut peak: u8 = 0;
|
||||
for &b in &bytes[start..end] {
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
|
||||
}
|
||||
let mut out = peak_half.clone();
|
||||
out.extend_from_slice(&peak_half);
|
||||
out
|
||||
}
|
||||
|
||||
struct PcmScanResult {
|
||||
bins: Vec<u8>,
|
||||
loudness: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
|
||||
fn analyze_loudness_and_waveform(
|
||||
bytes: &[u8],
|
||||
target_lufs: f64,
|
||||
bin_count: usize,
|
||||
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
|
||||
if bytes.is_empty() || bin_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
|
||||
if decoded_frames == 0 {
|
||||
return None;
|
||||
}
|
||||
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
|
||||
let (i, t, r, tgt) = scanned.loudness?;
|
||||
Some((i, t, r, tgt, scanned.bins))
|
||||
}
|
||||
|
||||
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
|
||||
/// `codec_params.n_frames` when the container reports total track length — used
|
||||
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
|
||||
/// when the buffer grows.
|
||||
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
|
||||
let source = Box::new(Cursor::new(bytes.to_vec()));
|
||||
let mss = MediaSourceStream::new(source, Default::default());
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.ok()?;
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.default_track()
|
||||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.or_else(|| {
|
||||
format.tracks().iter().find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
&& t.codec_params.channels.is_some()
|
||||
})
|
||||
})
|
||||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||||
let track_id = track.id;
|
||||
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
|
||||
let codec_params = track.codec_params.clone();
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&codec_params, &DecoderOptions::default())
|
||||
.ok()?;
|
||||
|
||||
let mut total: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(buf) => buf,
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let n = samples.samples().len();
|
||||
if n < n_ch || n % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
total += (n / n_ch) as u64;
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((total, timeline_hint))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
|
||||
let bin_count = bin_max.len();
|
||||
if bin_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut sorted: Vec<f32> = bin_max.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let range = (p99 - p5).max(1e-8);
|
||||
let mut out = vec![0u8; bin_count];
|
||||
for i in 0..bin_count {
|
||||
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
|
||||
let shaped = t.powf(0.52);
|
||||
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_scan_pcm(
|
||||
bytes: &[u8],
|
||||
bin_count: usize,
|
||||
decoded_frames: u64,
|
||||
timeline_hint: Option<u64>,
|
||||
loudness_target_lufs: Option<f64>,
|
||||
) -> Option<PcmScanResult> {
|
||||
let source = Box::new(Cursor::new(bytes.to_vec()));
|
||||
let mss = MediaSourceStream::new(source, Default::default());
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.ok()?;
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.default_track()
|
||||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.or_else(|| {
|
||||
format.tracks().iter().find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
&& t.codec_params.channels.is_some()
|
||||
})
|
||||
})
|
||||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||||
let track_id = track.id;
|
||||
let codec_params = track.codec_params.clone();
|
||||
let mut decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut bin_max = vec![0.0f32; bin_count];
|
||||
let mut bin_sum = vec![0.0f32; bin_count];
|
||||
let mut bin_n = vec![0u32; bin_count];
|
||||
let mut ebu: Option<EbuR128> = None;
|
||||
let mut ebu_channels: u32 = 0;
|
||||
let mut sample_peak_abs = 0.0_f64;
|
||||
let mut fed_any_frames = false;
|
||||
let mut sample_idx: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
// Fixed timeline from metadata when available; otherwise fall back to decoded
|
||||
// length (full-buffer analysis only — partial byte windows still shift, but
|
||||
// then we usually lack n_frames anyway).
|
||||
let bin_grid_frames = timeline_hint
|
||||
.map(|n| n.max(decoded_frames))
|
||||
.unwrap_or(decoded_frames)
|
||||
.max(1);
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(buf) => buf,
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() && ebu.is_none() {
|
||||
let ch = spec.channels.count() as u32;
|
||||
let sr = spec.rate;
|
||||
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
||||
Ok(v) => {
|
||||
ebu = Some(v);
|
||||
ebu_channels = ch;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
|
||||
ch,
|
||||
sr,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let slice = samples.samples();
|
||||
if slice.len() < n_ch || slice.len() % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
let frames = slice.len() / n_ch;
|
||||
|
||||
for f in 0..frames {
|
||||
let base = f * n_ch;
|
||||
let mut acc = 0.0f32;
|
||||
for c in 0..n_ch {
|
||||
acc += slice[base + c];
|
||||
}
|
||||
let mono = acc / (n_ch as f32);
|
||||
let mag = mono.abs();
|
||||
if mag.is_finite() {
|
||||
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
|
||||
let bin = bin.min(bin_count.saturating_sub(1));
|
||||
bin_max[bin] = bin_max[bin].max(mag);
|
||||
bin_sum[bin] += mag;
|
||||
bin_n[bin] = bin_n[bin].saturating_add(1);
|
||||
}
|
||||
for c in 0..n_ch {
|
||||
let v = (slice[base + c] as f64).abs();
|
||||
if v.is_finite() && v > sample_peak_abs {
|
||||
sample_peak_abs = v;
|
||||
}
|
||||
}
|
||||
sample_idx += 1;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() {
|
||||
if let Some(e) = ebu.as_mut() {
|
||||
match e.add_frames_f32(samples.samples()) {
|
||||
Ok(_) => fed_any_frames = true,
|
||||
Err(err) => {
|
||||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
let mut bin_mean = vec![0.0f32; bin_count];
|
||||
for i in 0..bin_count {
|
||||
if bin_n[i] > 0 {
|
||||
bin_mean[i] = bin_sum[i] / (bin_n[i] as f32);
|
||||
}
|
||||
}
|
||||
let peak_u8 = normalize_peak_bins(&bin_max);
|
||||
let mean_u8 = normalize_peak_bins(&bin_mean);
|
||||
let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2));
|
||||
bins.extend_from_slice(&peak_u8);
|
||||
bins.extend_from_slice(&mean_u8);
|
||||
|
||||
let loudness = if let Some(target_lufs) = loudness_target_lufs {
|
||||
if !fed_any_frames {
|
||||
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
|
||||
return None;
|
||||
}
|
||||
let Some(ebu) = ebu else {
|
||||
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
|
||||
return None;
|
||||
};
|
||||
let integrated_lufs = match ebu.loudness_global() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !integrated_lufs.is_finite() {
|
||||
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
|
||||
return None;
|
||||
}
|
||||
let mut true_peak = 0.0_f64;
|
||||
let mut true_peak_ok = true;
|
||||
for ch in 0..ebu_channels {
|
||||
match ebu.true_peak(ch) {
|
||||
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
true_peak_ok = false;
|
||||
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !true_peak_ok {
|
||||
true_peak = sample_peak_abs;
|
||||
}
|
||||
let recommended_gain_db =
|
||||
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(PcmScanResult { bins, loudness })
|
||||
}
|
||||
|
||||
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(base.join("audio-analysis.sqlite"))
|
||||
}
|
||||
|
||||
fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS analysis_track (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waveform_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loudness_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
+1672
-152
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{BufRead, BufReader, Read, Seek, SeekFrom},
|
||||
};
|
||||
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
@@ -88,6 +92,41 @@ pub fn wants_info_json(args: &[String]) -> bool {
|
||||
wants_info(args) && args.iter().skip(1).any(|a| a == "--json")
|
||||
}
|
||||
|
||||
pub fn wants_tail(args: &[String]) -> bool {
|
||||
args.iter().skip(1).any(|a| a == "--tail")
|
||||
}
|
||||
|
||||
pub fn wants_logs(args: &[String]) -> bool {
|
||||
args.iter().skip(1).any(|a| a == "--logs")
|
||||
}
|
||||
|
||||
pub fn wants_follow(args: &[String]) -> bool {
|
||||
args.iter().skip(1).any(|a| a == "-f" || a == "--follow")
|
||||
}
|
||||
|
||||
pub fn logs_tail_lines(args: &[String]) -> Result<Option<usize>, String> {
|
||||
let mut i = 1usize;
|
||||
while i < args.len() {
|
||||
if args[i] == "--tail" {
|
||||
let Some(raw) = args.get(i + 1) else {
|
||||
return Err("--tail requires a numeric value".to_string());
|
||||
};
|
||||
if raw.starts_with('-') {
|
||||
return Err("--tail requires a positive integer value".to_string());
|
||||
}
|
||||
let n: usize = raw
|
||||
.parse()
|
||||
.map_err(|_| "--tail requires a positive integer value".to_string())?;
|
||||
if n == 0 {
|
||||
return Err("--tail must be greater than 0".to_string());
|
||||
}
|
||||
return Ok(Some(n));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn wants_quiet(args: &[String]) -> bool {
|
||||
args.iter()
|
||||
.skip(1)
|
||||
@@ -229,6 +268,10 @@ pub fn print_help(program: &str) {
|
||||
eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus.");
|
||||
eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not");
|
||||
eprintln!(" published a snapshot yet.\n");
|
||||
eprintln!("── Logs channel (normal + debug) ──");
|
||||
eprintln!(" {program} --logs Print recent log lines and exit.");
|
||||
eprintln!(" {program} --logs --tail <lines> Print the last <lines> entries.");
|
||||
eprintln!(" {program} --logs --tail <lines> -f Keep streaming new lines.\n");
|
||||
eprintln!("── Remote commands (--player …) ──");
|
||||
eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS.");
|
||||
eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window.");
|
||||
@@ -267,6 +310,88 @@ pub fn print_help(program: &str) {
|
||||
eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status.");
|
||||
}
|
||||
|
||||
const CLI_TAIL_DEFAULT_LINES: usize = 200;
|
||||
|
||||
fn print_log_tail_once(path: &std::path::Path, lines: usize) -> Result<u64, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.map_err(|e| format!("open {}: {e}", path.display()))?;
|
||||
let mut ring: VecDeque<String> = VecDeque::with_capacity(lines.max(1));
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if ring.len() >= lines.max(1) {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back(line.trim_end_matches('\n').to_string());
|
||||
}
|
||||
for row in ring {
|
||||
println!("{row}");
|
||||
}
|
||||
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
fn follow_log_file(path: &std::path::Path, mut offset: u64) -> Result<(), String> {
|
||||
loop {
|
||||
let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
if len < offset {
|
||||
offset = 0;
|
||||
}
|
||||
if len > offset {
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.map_err(|e| format!("open {}: {e}", path.display()))?;
|
||||
f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?;
|
||||
let mut chunk = String::new();
|
||||
f.read_to_string(&mut chunk).map_err(|e| e.to_string())?;
|
||||
if !chunk.is_empty() {
|
||||
print!("{chunk}");
|
||||
}
|
||||
offset = len;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// Print from the shared normal/debug channel and exit.
|
||||
pub fn run_tail_and_exit(args: &[String]) -> ! {
|
||||
let tail_lines = match logs_tail_lines(args) {
|
||||
Ok(Some(n)) => n,
|
||||
Ok(None) => CLI_TAIL_DEFAULT_LINES,
|
||||
Err(e) => {
|
||||
eprintln!("NOT OK: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let path = crate::logging::cli_log_channel_path();
|
||||
if !path.exists() {
|
||||
eprintln!("NOT OK: no log channel file yet at {}", path.display());
|
||||
std::process::exit(3);
|
||||
}
|
||||
let offset = match print_log_tail_once(&path, tail_lines) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
eprintln!("NOT OK: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if wants_follow(args) {
|
||||
if let Err(e) = follow_log_file(&path, offset) {
|
||||
eprintln!("NOT OK: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`.
|
||||
fn read_library_cli_response_blocking(max_wait: Duration) -> String {
|
||||
let path = cli_library_response_path();
|
||||
|
||||
@@ -210,9 +210,26 @@ fn cache_and_return(
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
///
|
||||
/// In debug builds (i.e. `npx tauri dev`) every step of the IPC handshake is
|
||||
/// logged so the renderer's terminal output shows exactly where the
|
||||
/// connection breaks. Release builds stay completely silent.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
|
||||
client.connect().ok()?;
|
||||
let mut client = match DiscordIpcClient::new(DISCORD_APP_ID) {
|
||||
Ok(c) => c,
|
||||
Err(_e) => {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] new() failed (app_id={}): {}", DISCORD_APP_ID, _e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(_e) = client.connect() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
|
||||
return None;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] IPC connected (app_id={})", DISCORD_APP_ID);
|
||||
Some(client)
|
||||
}
|
||||
|
||||
@@ -316,7 +333,9 @@ pub async fn discord_update_presence(
|
||||
// When paused: clear activity completely to avoid any timer issues
|
||||
// When playing: show full activity with timer
|
||||
if !is_playing {
|
||||
if client.clear_activity().is_err() {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity (pause) failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
}
|
||||
return Ok(());
|
||||
@@ -339,8 +358,17 @@ pub async fn discord_update_presence(
|
||||
Timestamps::new()
|
||||
});
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
if let Err(_e) = client.set_activity(activity) {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] set_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!(
|
||||
"[discord] activity sent: \"{}\" / \"{}\"",
|
||||
details_text,
|
||||
state_text
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -351,8 +379,13 @@ pub async fn discord_update_presence(
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] activity cleared");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+2106
-113
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
#[cfg(unix)]
|
||||
use libc;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Write;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum LoggingMode {
|
||||
Off = 0,
|
||||
Normal = 1,
|
||||
Debug = 2,
|
||||
}
|
||||
|
||||
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
|
||||
const LOG_BUFFER_MAX_LINES: usize = 20_000;
|
||||
|
||||
fn log_buffer() -> &'static Mutex<VecDeque<String>> {
|
||||
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
|
||||
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
|
||||
}
|
||||
|
||||
/// Shared runtime file used by CLI `--tail` to read normal/debug log channel.
|
||||
pub fn cli_log_channel_path() -> std::path::PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return std::path::PathBuf::from(dir).join("psysonic-cli.log");
|
||||
}
|
||||
}
|
||||
std::env::temp_dir().join("psysonic-cli.log")
|
||||
}
|
||||
|
||||
fn parse_logging_mode(mode: &str) -> Option<LoggingMode> {
|
||||
match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"off" => Some(LoggingMode::Off),
|
||||
"normal" => Some(LoggingMode::Normal),
|
||||
"debug" => Some(LoggingMode::Debug),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> {
|
||||
let parsed = parse_logging_mode(mode)
|
||||
.ok_or_else(|| "invalid logging mode (expected: off | normal | debug)".to_string())?;
|
||||
LOGGING_MODE.store(parsed as u8, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_mode() -> LoggingMode {
|
||||
match LOGGING_MODE.load(Ordering::Acquire) {
|
||||
0 => LoggingMode::Off,
|
||||
2 => LoggingMode::Debug,
|
||||
_ => LoggingMode::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_log_normal() -> bool {
|
||||
!matches!(current_mode(), LoggingMode::Off)
|
||||
}
|
||||
|
||||
pub fn should_log_debug() -> bool {
|
||||
matches!(current_mode(), LoggingMode::Debug)
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
if buf.len() >= LOG_BUFFER_MAX_LINES {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line.clone());
|
||||
drop(buf);
|
||||
let path = cli_log_channel_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
|
||||
let snapshot = {
|
||||
let buf = log_buffer().lock().unwrap();
|
||||
if buf.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
};
|
||||
std::fs::write(path, snapshot).map_err(|e| e.to_string())?;
|
||||
let lines = {
|
||||
let buf = log_buffer().lock().unwrap();
|
||||
buf.len()
|
||||
};
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
pub(crate) fn log_timestamp_local() -> String {
|
||||
let now = ::std::time::SystemTime::now()
|
||||
.duration_since(::std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let millis = now.subsec_millis();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::ffi::CStr;
|
||||
let secs: libc::time_t = now.as_secs() as libc::time_t;
|
||||
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||
let mut date_buf: [libc::c_char; 64] = [0; 64];
|
||||
let mut tz_buf: [libc::c_char; 16] = [0; 16];
|
||||
let date_fmt = b"%Y-%m-%d %H:%M:%S\0";
|
||||
let tz_fmt = b"%z\0";
|
||||
|
||||
unsafe {
|
||||
if libc::localtime_r(&secs as *const libc::time_t, &mut tm as *mut libc::tm).is_null() {
|
||||
return format!("{}.{:03}", now.as_secs(), millis);
|
||||
}
|
||||
let date_ok = libc::strftime(
|
||||
date_buf.as_mut_ptr(),
|
||||
date_buf.len(),
|
||||
date_fmt.as_ptr().cast(),
|
||||
&tm as *const libc::tm,
|
||||
);
|
||||
if date_ok == 0 {
|
||||
return format!("{}.{:03}", now.as_secs(), millis);
|
||||
}
|
||||
let tz_ok = libc::strftime(
|
||||
tz_buf.as_mut_ptr(),
|
||||
tz_buf.len(),
|
||||
tz_fmt.as_ptr().cast(),
|
||||
&tm as *const libc::tm,
|
||||
);
|
||||
|
||||
let date = CStr::from_ptr(date_buf.as_ptr()).to_string_lossy();
|
||||
if tz_ok == 0 {
|
||||
return format!("{}.{:03}", date, millis);
|
||||
}
|
||||
let tz = CStr::from_ptr(tz_buf.as_ptr()).to_string_lossy();
|
||||
return format!("{}.{:03} {}", date, millis, tz);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
format!("{}.{:03}", now.as_secs(), millis)
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! app_eprintln {
|
||||
() => {{
|
||||
if $crate::logging::should_log_normal() {
|
||||
let ts = $crate::logging::log_timestamp_local();
|
||||
let line = format!("[{}]", ts);
|
||||
$crate::logging::append_log_line(line.clone());
|
||||
::std::eprintln!("{}", line);
|
||||
}
|
||||
}};
|
||||
($($arg:tt)*) => {{
|
||||
if $crate::logging::should_log_normal() {
|
||||
let ts = $crate::logging::log_timestamp_local();
|
||||
let line = format!("[{}] {}", ts, format_args!($($arg)*));
|
||||
$crate::logging::append_log_line(line.clone());
|
||||
::std::eprintln!("{}", line);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! app_deprintln {
|
||||
() => {{
|
||||
if $crate::logging::should_log_debug() {
|
||||
let ts = $crate::logging::log_timestamp_local();
|
||||
let line = format!("[{}]", ts);
|
||||
$crate::logging::append_log_line(line.clone());
|
||||
::std::eprintln!("{}", line);
|
||||
}
|
||||
}};
|
||||
($($arg:tt)*) => {{
|
||||
if $crate::logging::should_log_debug() {
|
||||
let ts = $crate::logging::log_timestamp_local();
|
||||
let line = format!("[{}] {}", ts, format_args!($($arg)*));
|
||||
$crate::logging::append_log_line(line.clone());
|
||||
::std::eprintln!("{}", line);
|
||||
}
|
||||
}};
|
||||
}
|
||||
+62
-1
@@ -1,17 +1,71 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum GpuVendor {
|
||||
Nvidia,
|
||||
Intel,
|
||||
Amd,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_gpu_vendor() -> Option<GpuVendor> {
|
||||
use std::fs;
|
||||
|
||||
if fs::metadata("/proc/driver/nvidia/version").is_ok() {
|
||||
return Some(GpuVendor::Nvidia);
|
||||
}
|
||||
|
||||
// Iterate every `/sys/class/drm/card*` — hybrid laptops expose multiple
|
||||
// cards, and some systems have no `card0` at all.
|
||||
let entries = fs::read_dir("/sys/class/drm").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if !name.starts_with("card") || name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let Ok(vendor_id) = fs::read_to_string(entry.path().join("device/vendor")) else {
|
||||
continue;
|
||||
};
|
||||
match vendor_id.trim() {
|
||||
"0x10de" => return Some(GpuVendor::Nvidia),
|
||||
"0x8086" => return Some(GpuVendor::Intel),
|
||||
"0x1002" => return Some(GpuVendor::Amd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
|
||||
// Users can still override by setting these vars before launch.
|
||||
//
|
||||
// Safety: set_var modifies global process state. These calls are safe here
|
||||
// because we're in main() before the Tauri runtime starts — no other threads
|
||||
// exist yet. If this code moves to lazy init or a plugin context, it would
|
||||
// need synchronization or marking as unsafe (Rust 2024+).
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
{
|
||||
if std::env::var("GDK_BACKEND").is_err() {
|
||||
std::env::set_var("GDK_BACKEND", "x11");
|
||||
}
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
|
||||
// NVIDIA proprietary adds a small but reproducible overhead on the
|
||||
// DMA-BUF renderer path (blind A/B confirmed on NVIDIA + proprietary).
|
||||
// Unknown GPUs keep the WebKitGTK default — VMs, ARM SBCs and anything
|
||||
// exotic should not be regressed by a guess.
|
||||
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
|
||||
&& matches!(detect_gpu_vendor(), Some(GpuVendor::Nvidia))
|
||||
{
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
@@ -31,6 +85,13 @@ fn main() {
|
||||
if psysonic_lib::cli::wants_info(&args) {
|
||||
psysonic_lib::cli::run_info_and_exit(&args);
|
||||
}
|
||||
if psysonic_lib::cli::wants_logs(&args) {
|
||||
psysonic_lib::cli::run_tail_and_exit(&args);
|
||||
}
|
||||
if psysonic_lib::cli::wants_tail(&args) {
|
||||
eprintln!("NOT OK: --tail is only valid with --logs");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
psysonic_lib::run();
|
||||
}
|
||||
|
||||
@@ -203,11 +203,11 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
||||
Err(e) => { crate::app_eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
||||
};
|
||||
|
||||
if let Err(e) = taskbar.HrInit() {
|
||||
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||
crate::app_eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
|
||||
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
||||
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
|
||||
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
||||
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
||||
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
||||
crate::app_eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
||||
drop(Box::from_raw(data));
|
||||
}
|
||||
}
|
||||
@@ -268,8 +268,7 @@ pub fn update_taskbar_icon(is_playing: bool) {
|
||||
|
||||
let mut btns = [btn];
|
||||
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||
crate::app_deprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.16",
|
||||
"version": "1.44.0-dev",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -23,8 +23,7 @@
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true,
|
||||
"dragDropEnabled": false,
|
||||
"devtools": false
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -33,7 +32,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUWFjOQo=",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUVBhYzkK",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
],
|
||||
|
||||
+295
-84
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } 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';
|
||||
@@ -11,9 +11,11 @@ import PlayerBar from './components/PlayerBar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import MobilePlayerView from './components/MobilePlayerView';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||
import QueuePanel from './components/QueuePanel';
|
||||
// Eager — main browsing flow, loaded on first paint
|
||||
import Home from './pages/Home';
|
||||
import Albums from './pages/Albums';
|
||||
import Artists from './pages/Artists';
|
||||
@@ -22,39 +24,57 @@ import NewReleases from './pages/NewReleases';
|
||||
import Favorites from './pages/Favorites';
|
||||
import RandomMix from './pages/RandomMix';
|
||||
import RandomLanding from './pages/RandomLanding';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import MostPlayed from './pages/MostPlayed';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import LuckyMixPage from './pages/LuckyMix';
|
||||
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 FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
|
||||
// Lazy — visited rarely or on-demand. Each becomes its own chunk so the
|
||||
// initial bundle stays smaller and these pages don't block first paint.
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const Statistics = lazy(() => import('./pages/Statistics'));
|
||||
const Help = lazy(() => import('./pages/Help'));
|
||||
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
|
||||
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
|
||||
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
|
||||
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
|
||||
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
|
||||
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
||||
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
||||
const Tracks = lazy(() => import('./pages/Tracks'));
|
||||
import MiniPlayer from './components/MiniPlayer';
|
||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
||||
import OrbitAccountPicker from './components/OrbitAccountPicker';
|
||||
import OrbitHelpModal from './components/OrbitHelpModal';
|
||||
import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import OverlayScrollArea from './components/OverlayScrollArea';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
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 TitleBar from './components/TitleBar';
|
||||
import { IS_LINUX } from './utils/platform';
|
||||
import OrbitSessionBar from './components/OrbitSessionBar';
|
||||
import OrbitStartTrigger from './components/OrbitStartTrigger';
|
||||
import { useOrbitHost } from './hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from './hooks/useOrbitGuest';
|
||||
import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit';
|
||||
import { useOrbitStore } from './store/orbitStore';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -73,7 +93,13 @@ import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import i18n from './i18n';
|
||||
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||
import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
||||
import {
|
||||
usePlayerStore,
|
||||
initAudioListeners,
|
||||
songToTrack,
|
||||
shuffleArray,
|
||||
flushPlayQueuePosition,
|
||||
} from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -82,6 +108,7 @@ import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './sto
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
import PasteClipboardHandler from './components/PasteClipboardHandler';
|
||||
|
||||
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
|
||||
let cliPremuteVolume: number | null = null;
|
||||
@@ -92,12 +119,47 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid grabbing the queue resizer when aiming at the main overlay scrollbar.
|
||||
* Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that).
|
||||
* Only the main-route thumb counts (not queue/mini/sidebar thumbs — selector is scoped).
|
||||
*
|
||||
* The queue resizer is 6px and sits on the main|queue seam with ~3px overlapping the main
|
||||
* column (layout.css `.resizer-queue`). Treating `clientX <= mainRight` as "main" suppressed
|
||||
* that overlap and felt like a dead resize strip at certain widths. Thumb hit slop must not
|
||||
* extend past `mainRight` or it steals grabs on the resizer.
|
||||
*/
|
||||
function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean {
|
||||
const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null;
|
||||
const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth;
|
||||
/** Pixels of the resizer that lie left of the main column's right edge (see `.resizer-queue`). */
|
||||
const RESIZER_BLEED_INTO_MAIN = 4;
|
||||
|
||||
if (clientX <= mainRight - RESIZER_BLEED_INTO_MAIN) return true;
|
||||
|
||||
const thumbs = document.querySelectorAll<HTMLElement>('.app-shell-route-scroll .overlay-scroll__thumb');
|
||||
const xSlop = 22;
|
||||
const vPad = 40;
|
||||
for (let i = 0; i < thumbs.length; i++) {
|
||||
const r = thumbs[i].getBoundingClientRect();
|
||||
if (r.height < 4 || r.width < 1) continue;
|
||||
if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue;
|
||||
const thumbHitRight = Math.min(r.right + xSlop, mainRight);
|
||||
if (clientX >= r.left - 6 && clientX <= thumbHitRight) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
const [isTilingWm, setIsTilingWm] = useState(false);
|
||||
|
||||
// Orbit session hooks: idle until the local store marks a role.
|
||||
useOrbitHost();
|
||||
useOrbitGuest();
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
@@ -110,6 +172,11 @@ function AppShell() {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown';
|
||||
document.documentElement.setAttribute('data-platform', platform);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const win = getCurrentWindow();
|
||||
// Check initial state (e.g. app launched maximised / already fullscreen).
|
||||
@@ -140,9 +207,22 @@ function AppShell() {
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
|
||||
// Mini player → main: route requests dispatched as `psy:navigate`
|
||||
// CustomEvents from the bridge land here so React Router can take over.
|
||||
useEffect(() => {
|
||||
const onPsyNavigate = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.to) navigate(detail.to);
|
||||
};
|
||||
window.addEventListener('psy:navigate', onPsyNavigate);
|
||||
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
||||
}, [navigate]);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
@@ -157,6 +237,10 @@ function AppShell() {
|
||||
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
|
||||
}, [linuxWebkitKineticScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_logging_mode', { mode: loggingMode }).catch(() => {});
|
||||
}, [loggingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
@@ -181,9 +265,17 @@ function AppShell() {
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
|
||||
|
||||
// Reset scroll position on route change
|
||||
// Orbit orphan sweep — delete our own leftover session / outbox playlists
|
||||
// from crashed or force-closed sessions so they don't clutter the ND
|
||||
// playlist view. Runs once per login; safe and best-effort.
|
||||
useEffect(() => {
|
||||
document.querySelector('.content-body')?.scrollTo({ top: 0 });
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
void cleanupOrphanedOrbitPlaylists();
|
||||
}, [isLoggedIn, activeServerId]);
|
||||
|
||||
// Reset scroll position on route change (main viewport is overlay scroll)
|
||||
useEffect(() => {
|
||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||
}, [location.pathname]);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
@@ -227,14 +319,9 @@ function AppShell() {
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
|
||||
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
|
||||
setChangelogModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
// Post-update changelog is now surfaced via a dismissible banner in the
|
||||
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
|
||||
// modal takeover on startup.
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
@@ -276,13 +363,11 @@ 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.
|
||||
// ── Global DnD fix for Linux/WebKitGTK / Wayland ─────────────────
|
||||
// dragover/dragenter: WebKitGTK needs preventDefault so external drops are not
|
||||
// a permanent "forbidden" cursor. dragstart (capture): cancel native drags from
|
||||
// the page (e.g. SVG grips); Wayland can otherwise leave a stuck GTK drag-proxy.
|
||||
// In-app moves use psy-drag (mouse events). Harmless on Windows/macOS.
|
||||
useEffect(() => {
|
||||
const allow = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -310,9 +395,14 @@ function AppShell() {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const blockDragStart = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('dragover', allow);
|
||||
document.addEventListener('dragenter', allow);
|
||||
document.addEventListener('drop', blockDrop);
|
||||
document.addEventListener('dragstart', blockDragStart, true);
|
||||
document.addEventListener('keydown', blockSelectAll, true);
|
||||
document.addEventListener('selectstart', blockSelectStart);
|
||||
|
||||
@@ -320,16 +410,29 @@ function AppShell() {
|
||||
document.removeEventListener('dragover', allow);
|
||||
document.removeEventListener('dragenter', allow);
|
||||
document.removeEventListener('drop', blockDrop);
|
||||
document.removeEventListener('dragstart', blockDragStart, true);
|
||||
document.removeEventListener('keydown', blockSelectAll, true);
|
||||
document.removeEventListener('selectstart', blockSelectStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pause CSS animations when the browser tab is hidden (`document.hidden`).
|
||||
// Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
|
||||
// Rust (see components.css). WebView2 can keep compositing without the former.
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
|
||||
};
|
||||
document.addEventListener('visibilitychange', update);
|
||||
update();
|
||||
return () => document.removeEventListener('visibilitychange', update);
|
||||
}, []);
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
@@ -355,6 +458,7 @@ function AppShell() {
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<OrbitStartTrigger />
|
||||
<button
|
||||
className="queue-toggle-btn"
|
||||
onClick={toggleQueue}
|
||||
@@ -364,38 +468,52 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <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 />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
className="app-shell-route-scroll"
|
||||
viewportClassName="app-shell-route-scroll__viewport"
|
||||
viewportId={APP_MAIN_SCROLL_VIEWPORT_ID}
|
||||
measureDeps={[location.pathname, isQueueVisible, queueWidth, floatingPlayerBar]}
|
||||
railInset="panel"
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<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 />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -404,6 +522,8 @@ function AppShell() {
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) return;
|
||||
if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return;
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
@@ -418,9 +538,11 @@ function AppShell() {
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -742,8 +864,10 @@ function TauriEventBridge() {
|
||||
// Configurable keybindings
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
const el = e.target as HTMLElement;
|
||||
const tag = el?.tagName;
|
||||
const editable = Boolean(el?.isContentEditable);
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
|
||||
|
||||
const chord = buildInAppBinding(e);
|
||||
if (chord) {
|
||||
@@ -790,6 +914,9 @@ function TauriEventBridge() {
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
case 'open-mini-player':
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -851,17 +978,47 @@ function TauriEventBridge() {
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
// Shared exit path: flush play-queue position so other devices can
|
||||
// resume from where we left off, tear down any active Orbit session,
|
||||
// then ask Rust to exit. Each step is capped at 1500 ms so a slow
|
||||
// server can't keep the app hanging on quit; the playback heartbeat
|
||||
// is the safety net for anything that didn't make it out in time.
|
||||
const performExit = async () => {
|
||||
await Promise.race([
|
||||
flushPlayQueuePosition(),
|
||||
new Promise(r => setTimeout(r, 1500)),
|
||||
]);
|
||||
const role = useOrbitStore.getState().role;
|
||||
if (role === 'host' || role === 'guest') {
|
||||
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
|
||||
await Promise.race([
|
||||
teardown.catch(() => {}),
|
||||
new Promise(r => setTimeout(r, 1500)),
|
||||
]);
|
||||
}
|
||||
await invoke('exit_app');
|
||||
};
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit) on
|
||||
// the X-button. JS decides: minimize to tray or exit.
|
||||
const u = await listen('window:close-requested', async () => {
|
||||
if (useAuthStore.getState().minimizeToTray) {
|
||||
await invoke('pause_rendering').catch(() => {});
|
||||
await getCurrentWindow().hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
await performExit();
|
||||
}
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
|
||||
// app:force-quit bypasses the minimize-to-tray decision — used by the
|
||||
// tray "Exit" menu item and the macOS red close button.
|
||||
const fq = await listen('app:force-quit', async () => {
|
||||
await performExit();
|
||||
});
|
||||
if (cancelled) { fq(); return; }
|
||||
unlisten.push(fq);
|
||||
};
|
||||
|
||||
setup();
|
||||
@@ -930,6 +1087,12 @@ export default function App() {
|
||||
const font = useFontStore(s => s.font);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
// Mini Player window: detected via Tauri window label. Rendered without
|
||||
// router / sidebar / full audio listeners — it just listens for state + sends
|
||||
// control events. Label is read synchronously from a global set in main.tsx
|
||||
// so the initial render picks the right tree.
|
||||
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
@@ -938,6 +1101,51 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// Main window only: push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
if (isMiniWindow) return;
|
||||
return initMiniPlayerBridgeOnMain();
|
||||
}, [isMiniWindow]);
|
||||
|
||||
// Main window only: optionally pre-create the mini player webview hidden so
|
||||
// the first open is instant. Windows already does this unconditionally in
|
||||
// Rust .setup() as a hang workaround — skip here to avoid double-building.
|
||||
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
|
||||
useEffect(() => {
|
||||
if (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
|
||||
invoke('preload_mini_player').catch(() => {});
|
||||
}, [isMiniWindow, preloadMiniPlayer]);
|
||||
|
||||
// Mini window only: re-hydrate persisted appearance stores when the main
|
||||
// window writes new values. Both webviews share localStorage (same origin),
|
||||
// so the `storage` event fires here whenever main mutates a key — but
|
||||
// Zustand persist only reads localStorage on initial load, hence the
|
||||
// explicit rehydrate.
|
||||
useEffect(() => {
|
||||
if (!isMiniWindow) return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (!e.key) return;
|
||||
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [isMiniWindow]);
|
||||
|
||||
if (isMiniWindow) {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<GlobalConfirmModal />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
||||
// because they live in separate grid cells. Document-level zoom is not used
|
||||
@@ -1003,23 +1211,26 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
<ZipDownloadOverlay />
|
||||
</BrowserRouter>
|
||||
<WindowVisibilityProvider>
|
||||
<BrowserRouter>
|
||||
<PasteClipboardHandler />
|
||||
<TauriEventBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
<ZipDownloadOverlay />
|
||||
</BrowserRouter>
|
||||
</WindowVisibilityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Snake_case payload from the Rust `fetch_bandsintown_events` command. */
|
||||
interface RawBandsintownEvent {
|
||||
datetime: string;
|
||||
venue_name: string;
|
||||
venue_city: string;
|
||||
venue_region: string;
|
||||
venue_country: string;
|
||||
url: string;
|
||||
on_sale_datetime: string;
|
||||
lineup: string[];
|
||||
}
|
||||
|
||||
export interface BandsintownEvent {
|
||||
datetime: string;
|
||||
venueName: string;
|
||||
venueCity: string;
|
||||
venueRegion: string;
|
||||
venueCountry: string;
|
||||
url: string;
|
||||
onSaleDatetime: string;
|
||||
lineup: string[];
|
||||
}
|
||||
|
||||
const cache = new Map<string, BandsintownEvent[]>();
|
||||
const inflight = new Map<string, Promise<BandsintownEvent[]>>();
|
||||
|
||||
function cacheKey(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch upcoming events for an artist. Results are cached in RAM for the session
|
||||
* (no TTL — restart drops them). Concurrent calls for the same artist share one
|
||||
* inflight promise. Failures resolve to an empty array — never throws.
|
||||
*/
|
||||
export async function fetchBandsintownEvents(artistName: string): Promise<BandsintownEvent[]> {
|
||||
const key = cacheKey(artistName);
|
||||
if (!key) return [];
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const raw = await invoke<RawBandsintownEvent[]>('fetch_bandsintown_events', {
|
||||
artistName,
|
||||
});
|
||||
const events: BandsintownEvent[] = (raw ?? []).map(r => ({
|
||||
datetime: r.datetime,
|
||||
venueName: r.venue_name,
|
||||
venueCity: r.venue_city,
|
||||
venueRegion: r.venue_region,
|
||||
venueCountry: r.venue_country,
|
||||
url: r.url,
|
||||
onSaleDatetime: r.on_sale_datetime,
|
||||
lineup: r.lineup ?? [],
|
||||
}));
|
||||
cache.set(key, events);
|
||||
return events;
|
||||
} catch {
|
||||
cache.set(key, []);
|
||||
return [];
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -300,3 +300,79 @@ export async function lastfmScrobble(
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmTrackInfo {
|
||||
listeners: number;
|
||||
playcount: number;
|
||||
userPlaycount: number | null;
|
||||
userLoved: boolean;
|
||||
tags: string[];
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export async function lastfmGetTrackInfo(
|
||||
artist: string,
|
||||
track: string,
|
||||
username?: string,
|
||||
): Promise<LastfmTrackInfo | null> {
|
||||
try {
|
||||
const params: Record<string, string> = { method: 'track.getInfo', artist, track };
|
||||
if (username) params.username = username;
|
||||
const data = await call(params, false, true);
|
||||
const t = data?.track;
|
||||
if (!t) return null;
|
||||
const rawTags = t.toptags?.tag;
|
||||
const tags = rawTags
|
||||
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
|
||||
: [];
|
||||
const userPc = t.userplaycount != null ? Number(t.userplaycount) : null;
|
||||
return {
|
||||
listeners: Number(t.listeners) || 0,
|
||||
playcount: Number(t.playcount) || 0,
|
||||
userPlaycount: Number.isFinite(userPc) ? userPc : null,
|
||||
userLoved: t.userloved === '1' || t.userloved === 1,
|
||||
tags,
|
||||
url: t.url ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmArtistStats {
|
||||
listeners: number;
|
||||
playcount: number;
|
||||
userPlaycount: number | null;
|
||||
tags: string[];
|
||||
url: string | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
export async function lastfmGetArtistStats(
|
||||
artist: string,
|
||||
username?: string,
|
||||
): Promise<LastfmArtistStats | null> {
|
||||
try {
|
||||
const params: Record<string, string> = { method: 'artist.getInfo', artist };
|
||||
if (username) params.username = username;
|
||||
const data = await call(params, false, true);
|
||||
const a = data?.artist;
|
||||
if (!a) return null;
|
||||
const rawTags = a.tags?.tag;
|
||||
const tags = rawTags
|
||||
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
|
||||
: [];
|
||||
const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null;
|
||||
const bioRaw = (a.bio?.content || a.bio?.summary || '').trim();
|
||||
return {
|
||||
listeners: Number(a.stats?.listeners) || 0,
|
||||
playcount: Number(a.stats?.playcount) || 0,
|
||||
userPlaycount: Number.isFinite(userPc) ? userPc : null,
|
||||
tags,
|
||||
url: a.url ?? null,
|
||||
bio: bioRaw || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface NdLibrary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NdUser {
|
||||
id: string;
|
||||
userName: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
libraryIds: number[];
|
||||
lastLoginAt?: string | null;
|
||||
lastAccessAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NdLoginResult {
|
||||
token: string;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function ndLogin(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<NdLoginResult> {
|
||||
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
||||
}
|
||||
|
||||
function extractLibraryIds(o: Record<string, unknown>): number[] {
|
||||
const libs = o.libraries;
|
||||
if (!Array.isArray(libs)) return [];
|
||||
const ids: number[] = [];
|
||||
for (const l of libs) {
|
||||
const id = (l as Record<string, unknown>)?.id;
|
||||
if (typeof id === 'number') ids.push(id);
|
||||
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
||||
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(u => {
|
||||
const o = u as Record<string, unknown>;
|
||||
return {
|
||||
id: String(o.id ?? ''),
|
||||
userName: String(o.userName ?? ''),
|
||||
name: String(o.name ?? ''),
|
||||
email: String(o.email ?? ''),
|
||||
isAdmin: !!o.isAdmin,
|
||||
libraryIds: extractLibraryIds(o),
|
||||
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
|
||||
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
|
||||
createdAt: o.createdAt as string | undefined,
|
||||
updatedAt: o.updatedAt as string | undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
|
||||
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(l => {
|
||||
const o = l as Record<string, unknown>;
|
||||
const id = typeof o.id === 'number'
|
||||
? o.id
|
||||
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
|
||||
return { id, name: String(o.name ?? '') };
|
||||
}).filter(l => l.id > 0);
|
||||
}
|
||||
|
||||
export async function ndSetUserLibraries(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
libraryIds: number[],
|
||||
): Promise<void> {
|
||||
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
|
||||
}
|
||||
|
||||
export async function ndCreateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<{ id: string }> {
|
||||
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
|
||||
const o = (raw as Record<string, unknown> | null) ?? {};
|
||||
return { id: String(o.id ?? '') };
|
||||
}
|
||||
|
||||
export async function ndUpdateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<void> {
|
||||
await invoke('nd_update_user', { serverUrl, token, id, ...data });
|
||||
}
|
||||
|
||||
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
||||
await invoke('nd_delete_user', { serverUrl, token, id });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
import type { SubsonicSong } from './subsonic';
|
||||
|
||||
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
|
||||
let cachedToken: { serverUrl: string; token: string } | null = null;
|
||||
|
||||
async function getToken(force = false): Promise<string> {
|
||||
const { getActiveServer, getBaseUrl } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!server || !baseUrl) throw new Error('No active server configured');
|
||||
if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token;
|
||||
const result = await ndLogin(baseUrl, server.username, server.password);
|
||||
cachedToken = { serverUrl: baseUrl, token: result.token };
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
// Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract.
|
||||
const id = asString(o.id ?? o.mediaFileId);
|
||||
const albumId = asString(o.albumId);
|
||||
return {
|
||||
id,
|
||||
title: asString(o.title),
|
||||
artist: asString(o.artist),
|
||||
album: asString(o.album),
|
||||
albumId,
|
||||
artistId: asString(o.artistId) || undefined,
|
||||
duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0,
|
||||
track: asNumber(o.trackNumber),
|
||||
discNumber: asNumber(o.discNumber),
|
||||
// Navidrome usually exposes coverArtId; many builds also accept the song id directly.
|
||||
coverArt: asString(o.coverArtId) || albumId || id || undefined,
|
||||
year: asNumber(o.year),
|
||||
userRating: asNumber(o.rating),
|
||||
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
bitRate: asNumber(o.bitRate),
|
||||
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
|
||||
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
|
||||
size: asNumber(o.size),
|
||||
samplingRate: asNumber(o.sampleRate),
|
||||
bitDepth: asNumber(o.bitDepth),
|
||||
};
|
||||
}
|
||||
|
||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count';
|
||||
|
||||
/**
|
||||
* Fetch a sorted, paginated slice of all songs via Navidrome's native REST API.
|
||||
* Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome.
|
||||
*/
|
||||
export async function ndListSongs(
|
||||
start: number,
|
||||
end: number,
|
||||
sort: NdSongSort = 'title',
|
||||
order: 'ASC' | 'DESC' = 'ASC',
|
||||
): Promise<SubsonicSong[]> {
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
|
||||
|
||||
let token = await getToken();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await callOnce(token);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
// Token rejected → re-auth once and retry
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
raw = await callOnce(token);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(s => mapNdSong(s as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Drop the cached token — call when the active server changes. */
|
||||
export function ndClearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
|
||||
export type SmartRuleOperator =
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'inTheRange';
|
||||
|
||||
export interface SmartRuleCondition {
|
||||
field: string;
|
||||
operator: SmartRuleOperator;
|
||||
value: string | number | boolean | [number, number];
|
||||
}
|
||||
|
||||
export interface NdSmartPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
duration?: number;
|
||||
rules?: Record<string, unknown>;
|
||||
sync?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function parseNdSmartPlaylist(raw: unknown, fallback: Partial<NdSmartPlaylist> = {}): NdSmartPlaylist {
|
||||
const o = (raw as Record<string, unknown>) ?? {};
|
||||
return {
|
||||
id: String(o.id ?? fallback.id ?? ''),
|
||||
name: String(o.name ?? fallback.name ?? ''),
|
||||
songCount: Number(o.songCount ?? fallback.songCount ?? 0),
|
||||
duration: typeof o.duration === 'number' ? o.duration : fallback.duration,
|
||||
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : fallback.rules,
|
||||
sync: typeof o.sync === 'boolean' ? o.sync : fallback.sync,
|
||||
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : fallback.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let authCache: {
|
||||
key: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
async function getNavidromeAuth(): Promise<{ serverUrl: string; token: string }> {
|
||||
const s = useAuthStore.getState();
|
||||
const server = s.getActiveServer();
|
||||
const serverUrl = s.getBaseUrl();
|
||||
if (!serverUrl || !server?.username || !server?.password) {
|
||||
throw new Error('No active server credentials');
|
||||
}
|
||||
const key = `${serverUrl}|${server.username}|${server.password}`;
|
||||
if (authCache && authCache.key === key && Date.now() < authCache.expiresAt) {
|
||||
return { serverUrl, token: authCache.token };
|
||||
}
|
||||
const login = await ndLogin(serverUrl, server.username, server.password);
|
||||
authCache = {
|
||||
key,
|
||||
token: login.token,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return { serverUrl, token: login.token };
|
||||
}
|
||||
|
||||
function conditionToRule(c: SmartRuleCondition): Record<string, unknown> {
|
||||
return { [c.operator]: { [c.field]: c.value } };
|
||||
}
|
||||
|
||||
export function buildSmartRules(conditions: SmartRuleCondition[], opts?: { limit?: number; sort?: string }) {
|
||||
const all = conditions.map(conditionToRule);
|
||||
const rules: Record<string, unknown> = { all };
|
||||
if (typeof opts?.limit === 'number' && opts.limit > 0) rules.limit = opts.limit;
|
||||
if (opts?.sort) rules.sort = opts.sort;
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function ndListSmartPlaylists(): Promise<NdSmartPlaylist[]> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_list_playlists', { serverUrl, token, smart: true });
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
|
||||
? (raw as { items: unknown[] }).items
|
||||
: [];
|
||||
return list.map((v) => parseNdSmartPlaylist(v));
|
||||
}
|
||||
|
||||
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_create_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndUpdateSmartPlaylist(
|
||||
id: string,
|
||||
name: string,
|
||||
rules: Record<string, unknown>,
|
||||
sync = true,
|
||||
): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_update_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
id,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { id, name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndGetSmartPlaylist(id: string): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_get_playlist', { serverUrl, token, id });
|
||||
return parseNdSmartPlaylist(raw, { id });
|
||||
}
|
||||
|
||||
export async function ndDeletePlaylist(id: string): Promise<void> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
await invoke('nd_delete_playlist', { serverUrl, token, id });
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Orbit — shared-session state types.
|
||||
*
|
||||
* The canonical state blob lives in the comment field of a dedicated
|
||||
* server-side playlist (`__psyorbit_[sid]__`). Host writes, guests read.
|
||||
* Per-user "outbox" playlists (`__psyorbit_[sid]_from_[username]__`)
|
||||
* carry suggestions + heartbeats the other way.
|
||||
*
|
||||
* This file is types + a few pure helpers only — no network, no store.
|
||||
*/
|
||||
|
||||
/** Bump whenever the on-wire schema changes incompatibly. */
|
||||
export const ORBIT_STATE_VERSION = 3 as const;
|
||||
|
||||
/** Prefix for the canonical session playlist name. Append an 8-hex session id. */
|
||||
export const ORBIT_PLAYLIST_PREFIX = '__psyorbit_';
|
||||
|
||||
/** Full canonical session playlist name for a given session id. */
|
||||
export function orbitSessionPlaylistName(sessionId: string): string {
|
||||
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}__`;
|
||||
}
|
||||
|
||||
/** Full per-user outbox playlist name. Host reads these, guests own one. */
|
||||
export function orbitOutboxPlaylistName(sessionId: string, username: string): string {
|
||||
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}_from_${username}__`;
|
||||
}
|
||||
|
||||
/** One queued/current track + who added it, for attribution. */
|
||||
export interface OrbitQueueItem {
|
||||
trackId: string;
|
||||
/** Navidrome username of the participant who suggested this track. */
|
||||
addedBy: string;
|
||||
/** Wall-clock ms when the host consumed this track from its originator's outbox. */
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
/** One participant's presence record. */
|
||||
export interface OrbitParticipant {
|
||||
user: string;
|
||||
/** Wall-clock ms when the host first registered this participant. */
|
||||
joinedAt: number;
|
||||
/** Wall-clock ms of the participant's most recent outbox heartbeat. */
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical session state — exactly what's serialised into the
|
||||
* session playlist's comment field. Keep lean; the comment has a ~4 KB
|
||||
* self-imposed budget.
|
||||
*/
|
||||
export interface OrbitState {
|
||||
v: typeof ORBIT_STATE_VERSION;
|
||||
/** Session id (8 hex chars). */
|
||||
sid: string;
|
||||
/** Navidrome username of the host. */
|
||||
host: string;
|
||||
/** Human-readable session name set by the host at start. */
|
||||
name: string;
|
||||
/** Epoch ms when the session was created. */
|
||||
started: number;
|
||||
/** Host-configurable cap on concurrent participants. */
|
||||
maxUsers: number;
|
||||
/** Currently-playing track (host's playback), or null when stopped. */
|
||||
currentTrack: OrbitQueueItem | null;
|
||||
/** Host's live play/pause state. */
|
||||
isPlaying: boolean;
|
||||
/** Host's last reported playback position in ms. */
|
||||
positionMs: number;
|
||||
/** Wall-clock ms of the `positionMs` snapshot, for drift calculation. */
|
||||
positionAt: number;
|
||||
/** Upcoming queue (not including `currentTrack`). */
|
||||
queue: OrbitQueueItem[];
|
||||
/**
|
||||
* Snapshot of the host's actual upcoming play queue (everything after
|
||||
* `queueIndex`), capped at `ORBIT_PLAY_QUEUE_LIMIT` to fit the state-blob
|
||||
* byte budget. Used by the guest view so guests see what's next in the
|
||||
* host's player rather than just the suggestions backlog. `addedBy`
|
||||
* carries the original suggester when known, otherwise the host.
|
||||
*/
|
||||
playQueue?: { trackId: string; addedBy: string }[];
|
||||
/**
|
||||
* Total length of the host's upcoming play queue, even when `playQueue`
|
||||
* was truncated. Lets the guest UI render a "+ N more" hint.
|
||||
*/
|
||||
playQueueTotal?: number;
|
||||
/** Epoch ms of the last queue shuffle. */
|
||||
lastShuffle: number;
|
||||
/** Currently-present participants (excluding the host). */
|
||||
participants: OrbitParticipant[];
|
||||
/** Usernames blocked from re-joining this session. */
|
||||
kicked: string[];
|
||||
/**
|
||||
* Soft-removed users — short-lived markers (TTL `ORBIT_REMOVED_TTL_MS`)
|
||||
* so the affected guest's next poll surfaces a "you were removed" modal.
|
||||
* Unlike `kicked`, the user is NOT blocked from re-joining via the
|
||||
* invite link. Aged out by the host's sweep tick.
|
||||
*/
|
||||
removed?: { user: string; at: number }[];
|
||||
/** Set when the host has ended the session; guests should exit on next poll. */
|
||||
ended?: boolean;
|
||||
/** Host-settable session rules; absent on older clients — treat missing as all-defaults. */
|
||||
settings?: OrbitSettings;
|
||||
/**
|
||||
* Usernames muted by the host: their outbox is still polled (so heartbeats
|
||||
* keep them visible as participants) but new track suggestions are dropped
|
||||
* before they reach the approval list. Symmetric — host can re-enable.
|
||||
*/
|
||||
suggestionBlocked?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-configurable rules. All default to `true`, i.e. the feature runs
|
||||
* "all on" for new sessions. Toggled via the Orbit-bar settings popover.
|
||||
*/
|
||||
/** Minute presets offered to the host in the Orbit settings popover. */
|
||||
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
|
||||
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number];
|
||||
|
||||
export interface OrbitSettings {
|
||||
/** Guest suggestions go straight into the host's play queue. */
|
||||
autoApprove: boolean;
|
||||
/** Whether the auto-shuffle cycle runs at all. */
|
||||
autoShuffle: boolean;
|
||||
/**
|
||||
* Minutes between each auto-shuffle cycle. Must be one of
|
||||
* `ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN`. Older sessions that predate this
|
||||
* field fall back to 15 via `effectiveShuffleIntervalMs`.
|
||||
*/
|
||||
shuffleIntervalMin?: OrbitShuffleIntervalMin;
|
||||
}
|
||||
|
||||
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
|
||||
// Off by default — host decides per suggestion via the approval list.
|
||||
autoApprove: false,
|
||||
autoShuffle: true,
|
||||
shuffleIntervalMin: 15,
|
||||
};
|
||||
|
||||
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
|
||||
export interface OrbitOutboxMeta {
|
||||
/** Wall-clock ms of this heartbeat. */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** Our self-imposed limit on serialised `OrbitState`. Drop oldest non-essential fields if exceeded. */
|
||||
export const ORBIT_STATE_MAX_BYTES = 4096;
|
||||
|
||||
/** Default value of `OrbitState.maxUsers` when the host hasn't picked one. */
|
||||
export const ORBIT_DEFAULT_MAX_USERS = 10;
|
||||
|
||||
/**
|
||||
* Hard cap on `playQueue` length. ~30 tracks × ~50 bytes each ≈ 1.5 KB,
|
||||
* leaving room for the rest of the state blob under `ORBIT_STATE_MAX_BYTES`.
|
||||
* Excess upcoming tracks are surfaced via the `playQueueTotal` count so the
|
||||
* guest UI can show a "+ N more" hint instead of pretending there's nothing.
|
||||
*/
|
||||
export const ORBIT_PLAY_QUEUE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Build a fresh state blob for a brand-new session. Used by the host on start.
|
||||
*/
|
||||
export function makeInitialOrbitState(args: {
|
||||
sid: string;
|
||||
host: string;
|
||||
name: string;
|
||||
maxUsers?: number;
|
||||
}): OrbitState {
|
||||
const now = Date.now();
|
||||
return {
|
||||
v: ORBIT_STATE_VERSION,
|
||||
sid: args.sid,
|
||||
host: args.host,
|
||||
name: args.name,
|
||||
started: now,
|
||||
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
|
||||
currentTrack: null,
|
||||
isPlaying: false,
|
||||
positionMs: 0,
|
||||
positionAt: now,
|
||||
queue: [],
|
||||
lastShuffle: now,
|
||||
participants: [],
|
||||
kicked: [],
|
||||
removed: [],
|
||||
suggestionBlocked: [],
|
||||
playQueue: [],
|
||||
playQueueTotal: 0,
|
||||
settings: { ...ORBIT_DEFAULT_SETTINGS },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + parse an incoming state blob (untrusted JSON from the playlist
|
||||
* comment). Returns null on structural mismatch or schema-version drift.
|
||||
*/
|
||||
export function parseOrbitState(raw: unknown): OrbitState | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const s = raw as Partial<OrbitState>;
|
||||
if (s.v !== ORBIT_STATE_VERSION) return null;
|
||||
if (typeof s.sid !== 'string' || typeof s.host !== 'string') return null;
|
||||
if (typeof s.name !== 'string' || typeof s.started !== 'number') return null;
|
||||
if (typeof s.maxUsers !== 'number' || typeof s.isPlaying !== 'boolean') return null;
|
||||
if (typeof s.positionMs !== 'number' || typeof s.positionAt !== 'number') return null;
|
||||
if (!Array.isArray(s.queue) || !Array.isArray(s.participants) || !Array.isArray(s.kicked)) return null;
|
||||
if (typeof s.lastShuffle !== 'number') return null;
|
||||
// currentTrack can be null or an object — no deeper validation here; the
|
||||
// producer is our own code and an item with missing fields would only hurt
|
||||
// the attribution UI, not correctness.
|
||||
// `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed.
|
||||
if (!Array.isArray(s.removed)) s.removed = [];
|
||||
// `suggestionBlocked` is optional too — older hosts predate the mute feature.
|
||||
if (!Array.isArray(s.suggestionBlocked)) s.suggestionBlocked = [];
|
||||
// `playQueue` / `playQueueTotal` are optional (older hosts won't write them).
|
||||
if (!Array.isArray(s.playQueue)) s.playQueue = [];
|
||||
if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0);
|
||||
return s as OrbitState;
|
||||
}
|
||||
|
||||
/** Quickly derive the host's estimated live playback position on a guest. */
|
||||
export function estimateLivePosition(state: OrbitState, nowMs: number): number {
|
||||
if (!state.isPlaying) return state.positionMs;
|
||||
return state.positionMs + (nowMs - state.positionAt);
|
||||
}
|
||||
+87
-15
@@ -9,6 +9,8 @@ import {
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
|
||||
const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
@@ -20,7 +22,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/${version}`, f: 'json' };
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
|
||||
}
|
||||
|
||||
export function getClient() {
|
||||
@@ -71,6 +73,8 @@ export interface SubsonicAlbum {
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
||||
isCompilation?: boolean;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
@@ -78,6 +82,8 @@ export interface SubsonicOpenArtistRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
userRating?: number;
|
||||
/** Navidrome / alternate OpenSubsonic payloads (same meaning as `userRating`). */
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -118,6 +124,14 @@ export interface SubsonicSong {
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
||||
displayComposer?: string;
|
||||
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
||||
contributors?: Array<{
|
||||
role: string;
|
||||
subRole?: string;
|
||||
artist: { id?: string; name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InternetRadioStation {
|
||||
@@ -285,7 +299,7 @@ export async function pingWithCredentials(
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' },
|
||||
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -297,7 +311,8 @@ export async function pingWithCredentials(
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -487,6 +502,30 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000)
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
genre?: string;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
}
|
||||
|
||||
/** Extended random song fetch with server-side year/genre filtering. */
|
||||
export async function getRandomSongsFiltered(
|
||||
filters: RandomSongsFilters,
|
||||
timeout = 15000,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
size: filters.size ?? 50,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
};
|
||||
if (filters.genre) params.genre = filters.genre;
|
||||
if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear;
|
||||
if (typeof filters.toYear === 'number') params.toYear = filters.toYear;
|
||||
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 });
|
||||
@@ -524,6 +563,17 @@ function parseEntityUserRating(v: unknown): number | undefined {
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
|
||||
export function parseSubsonicEntityStarRating(entity: {
|
||||
userRating?: unknown;
|
||||
rating?: unknown;
|
||||
}): number | undefined {
|
||||
return parseEntityUserRating(entity.userRating ?? entity.rating);
|
||||
}
|
||||
|
||||
/** Bump when rating parse keys change so stale cache entries are not reused. */
|
||||
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
|
||||
|
||||
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||
export async function prefetchArtistUserRatings(
|
||||
ids: string[],
|
||||
@@ -534,7 +584,7 @@ export async function prefetchArtistUserRatings(
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`artist:${id}`);
|
||||
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
@@ -547,8 +597,8 @@ export async function prefetchArtistUserRatings(
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseEntityUserRating(artist.userRating);
|
||||
setCachedRating(`artist:${id}`, r);
|
||||
const r = parseSubsonicEntityStarRating(artist);
|
||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -570,7 +620,7 @@ export async function prefetchAlbumUserRatings(
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`album:${id}`);
|
||||
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
@@ -583,8 +633,8 @@ export async function prefetchAlbumUserRatings(
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseEntityUserRating(album.userRating);
|
||||
setCachedRating(`album:${id}`, r);
|
||||
const r = parseSubsonicEntityStarRating(album);
|
||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -890,6 +940,23 @@ export async function search(query: string, options?: { albumCount?: number; art
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-only paginated search3. Tolerates empty query — Navidrome returns all songs
|
||||
* ordered by title in that case; strict Subsonic implementations may return nothing.
|
||||
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
|
||||
*/
|
||||
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
|
||||
query,
|
||||
artistCount: 0,
|
||||
albumCount: 0,
|
||||
songCount,
|
||||
songOffset,
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
return data.searchResult3?.song ?? [];
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
@@ -942,7 +1009,7 @@ export function buildStreamUrl(id: string): string {
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/stream.view?${p.toString()}`;
|
||||
}
|
||||
@@ -962,7 +1029,7 @@ export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const p = new URLSearchParams({
|
||||
id, size: String(size),
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
@@ -976,15 +1043,20 @@ export function buildDownloadUrl(id: string): string {
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/download.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
// ─── Playlists ────────────────────────────────────────────────
|
||||
export async function getPlaylists(): Promise<SubsonicPlaylist[]> {
|
||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view');
|
||||
return data.playlists?.playlist ?? [];
|
||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
|
||||
const all = data.playlists?.playlist ?? [];
|
||||
// Orbit session + outbox playlists are technical internals. They're `public`
|
||||
// so guests can reach them, which means they leak into every UI picker and
|
||||
// even into the Navidrome web client. Filter them out of every UI call;
|
||||
// orbit's own sweep passes `includeOrbit=true`.
|
||||
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
|
||||
}
|
||||
|
||||
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
|
||||
const TAPS_TO_REVEAL_HINT = 10;
|
||||
const TARGET_CLICKS_IN_WINDOW = 100;
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
/** Hardcoded About lol copy — intentionally not in locale files. */
|
||||
const MSG_HINT =
|
||||
'To become a developer, you need to click the Psysonic logo 100 times within one minute.';
|
||||
|
||||
const MSG_CONGRATS_TITLE = 'Congratulations.';
|
||||
const MSG_CONGRATS_SIGN_OFF = 'Sincerely, your maintainers.';
|
||||
const MSG_CONGRATS_PS = "PS: Don't forget to star the repo! ★";
|
||||
|
||||
/**
|
||||
* About page brand row + Settings → System → About lol (logo taps + modal).
|
||||
* Modal copy is English and hardcoded by design.
|
||||
*/
|
||||
export function AboutPsysonicBrandHeader({
|
||||
appVersion,
|
||||
aboutVersionLabel,
|
||||
}: {
|
||||
appVersion: string;
|
||||
aboutVersionLabel: string;
|
||||
}) {
|
||||
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
|
||||
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
|
||||
const [idleTaps, setIdleTaps] = useState(0);
|
||||
const [hintTimestamps, setHintTimestamps] = useState<number[]>([]);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
|
||||
const onLogoClick = useCallback(() => {
|
||||
if (phase === 'done') return;
|
||||
|
||||
if (phase === 'idle') {
|
||||
setIdleTaps(prev => {
|
||||
const next = prev + 1;
|
||||
if (next >= TAPS_TO_REVEAL_HINT) queueMicrotask(() => setPhase('hint'));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'hint') {
|
||||
const now = Date.now();
|
||||
setHintTimestamps(prev => {
|
||||
const inWindow = prev.filter(t => t > now - WINDOW_MS);
|
||||
const nextTimes = [...inWindow, now];
|
||||
if (nextTimes.length >= TARGET_CLICKS_IN_WINDOW) {
|
||||
queueMicrotask(() => {
|
||||
setPhase('done');
|
||||
setOverlayOpen(true);
|
||||
});
|
||||
}
|
||||
return nextTimes;
|
||||
});
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
const closeOverlay = useCallback(() => setOverlayOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlayOpen) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [overlayOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-about-header">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogoClick}
|
||||
className="about-psysonic-logo-lol-hit"
|
||||
aria-label="Psysonic"
|
||||
>
|
||||
<img src="/logo-psysonic.png" width={52} height={52} alt="" decoding="async" style={{ borderRadius: 14, display: 'block' }} />
|
||||
</button>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{aboutVersionLabel} {appVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === 'hint' && !overlayOpen && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '0.5rem',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{MSG_HINT}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{overlayOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="about-psysonic-lol-title"
|
||||
className="about-psysonic-lol-overlay"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="about-psysonic-lol-close"
|
||||
aria-label="Close"
|
||||
onClick={closeOverlay}
|
||||
>
|
||||
<X size={26} strokeWidth={2.25} aria-hidden />
|
||||
</button>
|
||||
<div className="about-psysonic-lol-panel">
|
||||
<div className="about-psysonic-lol-logo-slot">
|
||||
<PsysonicLogo
|
||||
gradientIdSuffix={modalWordmarkGradSuffix}
|
||||
className="about-psysonic-lol-logo-mark"
|
||||
style={{
|
||||
height: 'clamp(3.25rem, 14vw, 5.75rem)',
|
||||
width: 'auto',
|
||||
maxWidth: 'min(100%, 420px)',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="about-psysonic-lol-copy">
|
||||
<h2 id="about-psysonic-lol-title" className="about-psysonic-lol-title">
|
||||
{MSG_CONGRATS_TITLE}
|
||||
</h2>
|
||||
<p className="about-psysonic-lol-lede">
|
||||
{"We're very much looking forward to you as a developer — join us on "}
|
||||
<button
|
||||
type="button"
|
||||
className="about-psysonic-lol-inline-link"
|
||||
onClick={() => void openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
{' and build great features!'}
|
||||
</p>
|
||||
<p className="about-psysonic-lol-signoff">{MSG_CONGRATS_SIGN_OFF}</p>
|
||||
<p className="about-psysonic-lol-ps">{MSG_CONGRATS_PS}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from './CachedImage';
|
||||
@@ -19,8 +20,10 @@ interface AlbumCardProps {
|
||||
}
|
||||
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
@@ -94,9 +97,28 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
data-tooltip={t('hero.playAlbum')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
data-tooltip={t('contextMenu.enqueueAlbum')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<ListPlus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -9,6 +9,8 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -130,6 +132,16 @@ export default function AlbumHeader({
|
||||
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(' / ');
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
const ok = await copyEntityShareLink('album', info.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
@@ -242,6 +254,16 @@ export default function AlbumHeader({
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
type="button"
|
||||
onClick={handleShareAlbum}
|
||||
aria-label={t('albumDetail.shareAlbum')}
|
||||
data-tooltip={t('albumDetail.shareAlbum')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -321,6 +343,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleShareAlbum}
|
||||
aria-label={t('albumDetail.shareAlbum')}
|
||||
data-tooltip={t('albumDetail.shareAlbum')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
|
||||
@@ -57,6 +57,8 @@ interface AlbumTrackListProps {
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
|
||||
onDoubleClickSong?: (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;
|
||||
@@ -80,6 +82,7 @@ interface TrackRowProps {
|
||||
inSelectMode: boolean;
|
||||
isContextMenuSong: boolean;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onDoubleClickSong?: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: AlbumTrackListProps['onContextMenu'];
|
||||
@@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
inSelectMode,
|
||||
isContextMenuSong,
|
||||
onPlaySong,
|
||||
onDoubleClickSong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
@@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={onDoubleClickSong ? e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (e.ctrlKey || e.metaKey || inSelectMode) return;
|
||||
onDoubleClickSong(song);
|
||||
} : undefined}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
@@ -266,6 +275,7 @@ export default function AlbumTrackList({
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onDoubleClickSong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
@@ -644,6 +654,7 @@ export default function AlbumTrackList({
|
||||
inSelectMode={inSelectMode}
|
||||
isContextMenuSong={contextMenuSongId === song.id}
|
||||
onPlaySong={onPlaySong}
|
||||
onDoubleClickSong={onDoubleClickSong}
|
||||
onRate={onRate}
|
||||
onToggleSongStar={onToggleSongStar}
|
||||
onContextMenu={onContextMenu}
|
||||
|
||||
+142
-19
@@ -4,7 +4,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
@@ -103,7 +103,10 @@ export default function AppUpdater() {
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
const countdownRef = useRef<number | null>(null);
|
||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
@@ -152,19 +155,51 @@ export default function AppUpdater() {
|
||||
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
// On macOS the Tauri Updater handles architecture, signature verification
|
||||
// and in-place install — we don't need (and should not show) a DMG asset.
|
||||
const useTauriUpdater = IS_MACOS;
|
||||
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const startRestartCountdown = (seconds: number) => {
|
||||
let remaining = seconds;
|
||||
setCountdown(remaining);
|
||||
countdownRef.current = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
setCountdown(null);
|
||||
relaunchFnRef.current?.();
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
if (countdownRef.current) {
|
||||
window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setCountdown(null);
|
||||
await relaunchFnRef.current?.();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||
@@ -175,6 +210,8 @@ export default function AppUpdater() {
|
||||
setDlError('');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
relaunchFnRef.current = relaunch;
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
setDlError(t('common.updaterErrorMsg'));
|
||||
@@ -192,9 +229,12 @@ export default function AppUpdater() {
|
||||
setDlProgress({ bytes: downloaded, total });
|
||||
} else if (event.event === 'Finished') {
|
||||
setDlState('done');
|
||||
// downloadAndInstall replaces the .app in place but does not exit
|
||||
// the running process. Give the user a 3s countdown (with a manual
|
||||
// "Restart now" button) before auto-relaunch.
|
||||
startRestartCountdown(3);
|
||||
}
|
||||
});
|
||||
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
@@ -309,6 +349,59 @@ export default function AppUpdater() {
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
@@ -356,25 +449,55 @@ export default function AppUpdater() {
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{!showAurHint && asset && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
{dlState === 'idle' && (
|
||||
<>
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
|
||||
{dlState === 'done' && useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'done' && !useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
@@ -38,7 +40,7 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
{t('artists.albumCount', { count: artist.albumCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
|
||||
import { Disc3, Search, Music4, AudioLines, MoreHorizontal } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import MobileSearchOverlay from './MobileSearchOverlay';
|
||||
import MobileMoreOverlay from './MobileMoreOverlay';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
|
||||
@@ -16,6 +17,7 @@ export default function BottomNav() {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,9 +49,21 @@ export default function BottomNav() {
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t('search.title')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bottom-nav-item${moreOpen ? ' active' : ''}`}
|
||||
onClick={() => setMoreOpen(v => !v)}
|
||||
aria-label={t('sidebar.more')}
|
||||
>
|
||||
<span className="bottom-nav-icon-wrap">
|
||||
<MoreHorizontal size={22} />
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t('sidebar.more')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
|
||||
{moreOpen && <MobileMoreOverlay onClose={() => setMoreOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -7,22 +7,69 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shared, refcounted object URL for a cached image. Multiple
|
||||
* consumers of the same cacheKey see the exact same URL string, so the
|
||||
* browser's decoded-image cache hits across instances — critical on
|
||||
* Chromium/WebView2 (Windows), which keys decode results by URL.
|
||||
*
|
||||
* @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('');
|
||||
// Synchronously acquire on first render when the blob is already hot. This
|
||||
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl
|
||||
// swap that would trigger a redundant network request and decode pass.
|
||||
const [resolved, setResolved] = useState(() => fetchUrl ? (acquireUrl(cacheKey) ?? '') : '');
|
||||
// Tracks whichever cacheKey we currently hold a refcount on, so we know
|
||||
// exactly what to release on cleanup or when keys change.
|
||||
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
const controller = new AbortController();
|
||||
const release = () => {
|
||||
if (ownedKeyRef.current) {
|
||||
releaseUrl(ownedKeyRef.current);
|
||||
ownedKeyRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!fetchUrl) {
|
||||
release();
|
||||
setResolved('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy initializer (or a previous run) already acquired the right key.
|
||||
if (ownedKeyRef.current === cacheKey) return release;
|
||||
|
||||
// Different key than we're currently holding: drop the old one.
|
||||
release();
|
||||
|
||||
// Fast path: blob is hot in memory → grab the shared URL synchronously.
|
||||
const sync = acquireUrl(cacheKey);
|
||||
if (sync) {
|
||||
ownedKeyRef.current = cacheKey;
|
||||
setResolved(sync);
|
||||
return release;
|
||||
}
|
||||
|
||||
// Slow path: fetch (or read from IDB), then acquire.
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
||||
if (!controller.signal.aborted) setResolved(url);
|
||||
const controller = new AbortController();
|
||||
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
|
||||
if (controller.signal.aborted || !blob) return;
|
||||
const url = acquireUrl(cacheKey);
|
||||
if (!url) return;
|
||||
ownedKeyRef.current = cacheKey;
|
||||
setResolved(url);
|
||||
});
|
||||
return () => { controller.abort(); };
|
||||
return () => {
|
||||
controller.abort();
|
||||
release();
|
||||
};
|
||||
}, [fetchUrl, cacheKey]);
|
||||
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
@@ -43,7 +90,10 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
||||
// fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser
|
||||
// to start a server fetch, then abort it when we replace src with the blob URL —
|
||||
// visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sparkles } from 'lucide-react';
|
||||
import { version } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { findChangelogReleaseEntry } from '../utils/changelogReleaseMatch';
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
@@ -29,15 +30,7 @@ export default function ChangelogModal({ onClose }: Props) {
|
||||
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 currentVersionData = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShow) setShowChangelogOnUpdate(false);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
/**
|
||||
* Cancel button label. Omit (together with `onCancel`) to render the
|
||||
* modal as a single-button info dialog — Esc / outside-click / X then
|
||||
* also resolve via `onConfirm`.
|
||||
*/
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const dismiss = onCancel ?? onConfirm;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') dismiss();
|
||||
else if (e.key === 'Enter') onConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, dismiss, onConfirm]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const confirmStyle = danger
|
||||
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
|
||||
: undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={dismiss}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: '380px' }}
|
||||
>
|
||||
<button className="modal-close" onClick={dismiss} aria-label={cancelLabel ?? confirmLabel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||
{message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
{cancelLabel && onCancel && (
|
||||
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm} autoFocus={!cancelLabel}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -43,7 +44,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
|
||||
const goServerSettings = () => {
|
||||
setMenuOpen(false);
|
||||
navigate('/settings', { state: { tab: 'server' } });
|
||||
navigate('/settings', { state: { tab: 'servers' } });
|
||||
};
|
||||
|
||||
const onTriggerClick = () => {
|
||||
@@ -123,7 +124,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const labelText = (srv.name || srv.url).trim() || srv.url;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
key={srv.id}
|
||||
|
||||
+261
-70
@@ -1,5 +1,12 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
suggestOrbitTrack,
|
||||
hostEnqueueToOrbit,
|
||||
evaluateOrbitSuggestGate,
|
||||
OrbitSuggestBlockedError,
|
||||
} from '../utils/orbit';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
@@ -16,6 +23,25 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
import type { EntityShareKind } from '../utils/shareLink';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
import { useConfirmModalStore } from '../store/confirmModalStore';
|
||||
|
||||
/** Ask user before re-adding songs to a playlist when *all* selected ids are
|
||||
* already present. Returns true → caller should append them as duplicates,
|
||||
* false → caller should keep today's silent-skip toast behavior. */
|
||||
async function confirmAddAllDuplicates(
|
||||
playlistName: string,
|
||||
count: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): Promise<boolean> {
|
||||
return useConfirmModalStore.getState().request({
|
||||
title: t('playlists.duplicateConfirmTitle'),
|
||||
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
|
||||
confirmLabel: t('playlists.duplicateConfirmAction'),
|
||||
cancelLabel: t('common.cancel'),
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -25,6 +51,13 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
|
||||
const SMART_PLAYLIST_PREFIX = 'psy-smart-';
|
||||
|
||||
function isSmartPlaylistName(name: string | undefined | null): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX);
|
||||
}
|
||||
|
||||
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const result = [...arr];
|
||||
@@ -57,16 +90,18 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sort playlists by recent usage
|
||||
// Sort playlists by recent usage (exclude app smart playlists — not editable as normal lists)
|
||||
const playlists = useMemo(() => {
|
||||
return [...storePlaylists].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;
|
||||
});
|
||||
return [...storePlaylists]
|
||||
.filter(p => !isSmartPlaylistName(p.name))
|
||||
.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;
|
||||
});
|
||||
}, [storePlaylists, recentIds]);
|
||||
|
||||
// Flip submenu left if it would overflow the right edge of the viewport
|
||||
@@ -92,10 +127,17 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
||||
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
|
||||
touchPlaylist(pl.id);
|
||||
} else {
|
||||
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
||||
const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t);
|
||||
if (accepted) {
|
||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]);
|
||||
showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
||||
touchPlaylist(pl.id);
|
||||
} else {
|
||||
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
||||
}
|
||||
}
|
||||
touchPlaylist(pl.id);
|
||||
} catch {
|
||||
showToast(t('playlists.addError'), 3000, 'error');
|
||||
}
|
||||
@@ -255,34 +297,42 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
}
|
||||
|
||||
// Show detailed toast notification
|
||||
const totalSongs = songIds.length;
|
||||
const addedCount = newIds.length;
|
||||
const duplicateCount = duplicateIds.length;
|
||||
|
||||
if (addedCount === 0 && duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
if (addedCount > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} else if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
|
||||
if (accepted) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
|
||||
touchPlaylist(pl.id);
|
||||
showToast(
|
||||
t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('playlists.addError'), 4000, 'error');
|
||||
@@ -328,7 +378,9 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
||||
|
||||
// Sort playlists from store (no fetch needed, prevents flash)
|
||||
const playlists = useMemo(() => {
|
||||
return [...storePlaylists].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...storePlaylists]
|
||||
.filter(p => !isSmartPlaylistName(p.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [storePlaylists]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -489,33 +541,42 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
}
|
||||
|
||||
// Show detailed toast notification
|
||||
const addedCount = newIds.length;
|
||||
const duplicateCount = duplicateIds.length;
|
||||
|
||||
if (addedCount === 0 && duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
if (addedCount > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} else if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
|
||||
if (accepted) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
|
||||
touchPlaylist(pl.id);
|
||||
showToast(
|
||||
t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('playlists.addError'), 4000, 'error');
|
||||
@@ -537,7 +598,9 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists().then((all) => {
|
||||
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setPlaylists(
|
||||
all.filter(p => !isSmartPlaylistName(p.name)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -660,9 +723,11 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
||||
|
||||
// Filter out the current playlist from the list
|
||||
// Filter out the current playlist and smart playlists from the list
|
||||
const allPlaylists = useMemo(() => {
|
||||
return storePlaylists.filter((p) => p.id !== playlist.id);
|
||||
return storePlaylists.filter(
|
||||
(p) => p.id !== playlist.id && !isSmartPlaylistName(p.name),
|
||||
);
|
||||
}, [storePlaylists, playlist.id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -805,10 +870,12 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
||||
|
||||
// Filter out the selected playlists from the list
|
||||
// Filter out the selected playlists and smart playlists from the list
|
||||
const allPlaylists = useMemo(() => {
|
||||
const selectedIds = new Set(playlists.map(p => p.id));
|
||||
return storePlaylists.filter((p) => !selectedIds.has(p.id));
|
||||
return storePlaylists.filter(
|
||||
(p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name),
|
||||
);
|
||||
}, [storePlaylists, playlists]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -960,6 +1027,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
contextMenu: s.contextMenu,
|
||||
@@ -1079,6 +1147,22 @@ export default function ContextMenu() {
|
||||
});
|
||||
}, [contextMenu.isOpen]);
|
||||
|
||||
// Outside-click closes the menu without occluding the underlying UI. The
|
||||
// previous implementation rendered a transparent fullscreen backdrop, which
|
||||
// also blocked right-clicks from reaching elements *under* it — so users
|
||||
// couldn't reposition the menu by right-clicking another row.
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [contextMenu.isOpen, closeContextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return;
|
||||
let cancelled = false;
|
||||
@@ -1265,6 +1349,12 @@ export default function ContextMenu() {
|
||||
await action();
|
||||
};
|
||||
|
||||
const copyShareLink = useCallback(async (kind: EntityShareKind, id: string) => {
|
||||
const ok = await copyEntityShareLink(kind, id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}, [t]);
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
@@ -1401,11 +1491,6 @@ export default function ContextMenu() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
|
||||
onMouseDown={() => closeContextMenu()}
|
||||
/>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu animate-fade-in"
|
||||
@@ -1435,6 +1520,38 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
{orbitRole === 'guest' && (() => {
|
||||
const muted = evaluateOrbitSuggestGate().reason === 'muted';
|
||||
return (
|
||||
<div
|
||||
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
|
||||
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
|
||||
onClick={() => handleAction(() => {
|
||||
if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; }
|
||||
suggestOrbitTrack(song.id)
|
||||
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') {
|
||||
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
|
||||
} else {
|
||||
showToast(t('orbit.ctxSuggestFailed'), 3000, 'error');
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{orbitRole === 'host' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
hostEnqueueToOrbit(song.id)
|
||||
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||
})}>
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
@@ -1515,6 +1632,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -1564,6 +1684,38 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
{orbitRole === 'guest' && (() => {
|
||||
const muted = evaluateOrbitSuggestGate().reason === 'muted';
|
||||
return (
|
||||
<div
|
||||
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
|
||||
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
|
||||
onClick={() => handleAction(() => {
|
||||
if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; }
|
||||
suggestOrbitTrack(song.id)
|
||||
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') {
|
||||
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
|
||||
} else {
|
||||
showToast(t('orbit.ctxSuggestFailed'), 3000, 'error');
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{orbitRole === 'host' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
hostEnqueueToOrbit(song.id)
|
||||
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||
})}>
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
@@ -1627,6 +1779,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -1649,6 +1804,25 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
|
||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
if (tracks.length === 0) return;
|
||||
if (!currentTrack) {
|
||||
playTrack(tracks[0], tracks);
|
||||
return;
|
||||
}
|
||||
const currentIdx = usePlayerStore.getState().queueIndex;
|
||||
usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1);
|
||||
})}>
|
||||
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
enqueue(albumData.songs.map(songToTrack));
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
@@ -1679,6 +1853,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
@@ -1751,6 +1928,14 @@ export default function ContextMenu() {
|
||||
{t('contextMenu.selectedAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
|
||||
enqueue(allTracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
@@ -1787,6 +1972,9 @@ export default function ContextMenu() {
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('artist', artist.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
@@ -1975,6 +2163,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
|
||||
Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
import { extractCoverColors } from '../utils/dynamicColors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -47,23 +52,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const activeIdxRef = useRef(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const springRef = useRef<SpringScroller | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollerRef = useRef<EaseScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevWord = useRef({ line: -1, word: -1 });
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Create/destroy the SpringScroller when the container mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (el) {
|
||||
springRef.current = new SpringScroller(el, 0.1, 0.78);
|
||||
} else {
|
||||
springRef.current?.stop();
|
||||
springRef.current = null;
|
||||
}
|
||||
containerRef.current = el;
|
||||
scrollerRef.current?.stop();
|
||||
scrollerRef.current = el ? new EaseScroller(el) : null;
|
||||
}, []);
|
||||
|
||||
// Reset everything on track change.
|
||||
@@ -73,7 +73,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
prevWord.current = { line: -1, word: -1 };
|
||||
activeIdxRef.current = -1;
|
||||
setActiveIdx(-1);
|
||||
springRef.current?.jump(0);
|
||||
scrollerRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Subscribe to playback time — only triggers React setState when line changes.
|
||||
@@ -92,13 +92,13 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [hasSynced, currentTrack?.id]);
|
||||
|
||||
// Spring-scroll active line to ~35% from the top of the container.
|
||||
// Ease-scroll active line to ~35% from the top of the container.
|
||||
useEffect(() => {
|
||||
if (activeIdx < 0 || isUserScroll.current) return;
|
||||
const el = lineRefs.current[activeIdx];
|
||||
const box = containerRef.current;
|
||||
if (!el || !box || !springRef.current) return;
|
||||
springRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
||||
if (!el || !box || !scrollerRef.current) return;
|
||||
scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
||||
}, [activeIdx]);
|
||||
|
||||
// Word-sync: imperative DOM updates, zero React re-renders per tick.
|
||||
@@ -134,8 +134,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
const handleUserScroll = useCallback(() => {
|
||||
// Stop spring animation so it doesn't fight the user's scroll.
|
||||
springRef.current?.stop();
|
||||
scrollerRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
@@ -149,9 +148,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
|
||||
if (!currentTrack || loading) return null;
|
||||
|
||||
const isPlain = !hasSynced && !!plainLyrics;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsa-lyrics-container"
|
||||
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
|
||||
ref={setContainerRef}
|
||||
onWheel={handleUserScroll}
|
||||
onTouchMove={handleUserScroll}
|
||||
@@ -432,47 +433,52 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
const previewSeek = useCallback((progress: number) => {
|
||||
const s = usePlayerStore.getState();
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
pendingSeekRef.current = p;
|
||||
if (timeRef.current) {
|
||||
const previewTime = duration > 0 ? p * duration : s.currentTime;
|
||||
timeRef.current.textContent = formatTime(previewTime);
|
||||
}
|
||||
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(p);
|
||||
}, [duration]);
|
||||
|
||||
const commitSeek = useCallback(() => {
|
||||
const pending = pendingSeekRef.current;
|
||||
if (pending === null) return;
|
||||
pendingSeekRef.current = null;
|
||||
seek(pending);
|
||||
}, [seek]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cache the last applied values so we can skip DOM writes when nothing
|
||||
// changed. The store subscription fires on every state change (queue,
|
||||
// volume, currentTrack…), not just progress, so without this guard we
|
||||
// were writing identical width/textContent strings dozens of times per
|
||||
// second — each one triggers a style invalidation in WebKitGTK.
|
||||
let lastTime = -1;
|
||||
let lastPct = -1;
|
||||
let lastBufW = -1;
|
||||
let lastProg = -1;
|
||||
const s = usePlayerStore.getState();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
const apply = (s: ReturnType<typeof usePlayerStore.getState>) => {
|
||||
const pct = s.progress * 100;
|
||||
const bufW = Math.max(pct, s.buffered * 100);
|
||||
const sec = s.currentTime | 0;
|
||||
if (sec !== lastTime) {
|
||||
lastTime = sec;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
}
|
||||
if (pct !== lastPct) {
|
||||
lastPct = pct;
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
}
|
||||
if (bufW !== lastBufW) {
|
||||
lastBufW = bufW;
|
||||
if (bufRef.current) bufRef.current.style.width = `${bufW}%`;
|
||||
}
|
||||
if (s.progress !== lastProg) {
|
||||
lastProg = s.progress;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
}
|
||||
};
|
||||
|
||||
apply(usePlayerStore.getState());
|
||||
return usePlayerStore.subscribe(apply);
|
||||
return usePlayerStore.subscribe(state => {
|
||||
if (isDraggingRef.current) return;
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||
[seek]
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
previewSeek(parseFloat(e.target.value));
|
||||
},
|
||||
[previewSeek]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -490,6 +496,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
onMouseDown={() => { isDraggingRef.current = true; }}
|
||||
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onTouchStart={() => { isDraggingRef.current = true; }}
|
||||
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onPointerDown={() => { isDraggingRef.current = true; }}
|
||||
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onKeyUp={commitSeek}
|
||||
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
aria-label="seek"
|
||||
/>
|
||||
</div>
|
||||
@@ -502,8 +516,9 @@ interface FsLyricsMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: FsLyricsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
@@ -512,13 +527,16 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// setTimeout(0) defers listener registration past the current click cycle
|
||||
// so the button click that opens the panel doesn't immediately close it.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
@@ -527,7 +545,7 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -569,14 +587,40 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
|
||||
});
|
||||
|
||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const FsPlayBtn = memo(function FsPlayBtn({
|
||||
controlsAnchorRef,
|
||||
}: {
|
||||
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
return (
|
||||
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
</button>
|
||||
<>
|
||||
<span ref={playSlotRef} className="playback-transport-play-wrap">
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
|
||||
<button
|
||||
type="button"
|
||||
className="fs-btn fs-btn-play"
|
||||
{...playPauseBind}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{scheduleRemaining != null ? (
|
||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--fs`}>
|
||||
{scheduleRemaining.mode === 'pause'
|
||||
? <Moon size={12} strokeWidth={2.5} />
|
||||
: <Sunrise size={12} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -696,12 +740,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
if (!nextCoverArt) return;
|
||||
const url = buildCoverArtUrl(nextCoverArt, 300);
|
||||
const key = coverArtCacheKey(nextCoverArt, 300);
|
||||
getCachedUrl(url, key).catch(() => {});
|
||||
getCachedBlob(url, key).catch(() => {});
|
||||
}, [nextCoverArt]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
@@ -808,14 +854,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls">
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<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')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
@@ -838,8 +884,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
|
||||
+160
-103
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Filter, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getGenres } from '../api/subsonic';
|
||||
|
||||
@@ -13,8 +14,10 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
|
||||
const [open, setOpen] = useState(false);
|
||||
const [genres, setGenres] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,124 +26,178 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
|
||||
);
|
||||
}, []);
|
||||
|
||||
// close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
// sync open state with selection
|
||||
useEffect(() => {
|
||||
if (selected.length > 0) setOpen(true);
|
||||
}, [selected]);
|
||||
// Selected on top, then alphabetical (stable for comfortable scanning).
|
||||
const sortedGenres = useMemo(() => {
|
||||
const arr = [...genres];
|
||||
arr.sort((a, b) => {
|
||||
const sa = selectedSet.has(a) ? 0 : 1;
|
||||
const sb = selectedSet.has(b) ? 0 : 1;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return arr;
|
||||
}, [genres, selectedSet]);
|
||||
|
||||
const filteredOptions = genres.filter(
|
||||
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const filteredGenres = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return sortedGenres;
|
||||
return sortedGenres.filter(g => g.toLowerCase().includes(q));
|
||||
}, [sortedGenres, search]);
|
||||
|
||||
const add = (genre: string) => {
|
||||
onSelectionChange([...selected, genre]);
|
||||
setSearch('');
|
||||
inputRef.current?.focus();
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 280;
|
||||
const MAX_H = 360;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (genre: string) => {
|
||||
onSelectionChange(selected.filter(s => s !== genre));
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => updatePopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!popRef.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]);
|
||||
|
||||
const toggle = (genre: string) => {
|
||||
if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre));
|
||||
else onSelectionChange([...selected, genre]);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onSelectionChange([]);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const openFilter = () => {
|
||||
setOpen(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<Filter size={14} />
|
||||
{t('common.filterGenre')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
// relatedTarget is the next focused element; if it's outside our container, handle close
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (containerRef.current && next && containerRef.current.contains(next)) return;
|
||||
setTimeout(() => {
|
||||
if (selected.length === 0) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setDropdownOpen(false);
|
||||
} else {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
const count = selected.length;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`btn btn-surface${count > 0 ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<Filter size={14} />
|
||||
{t('common.filterGenre')}
|
||||
{count > 0 && <span className="genre-filter-count">{count}</span>}
|
||||
</button>
|
||||
|
||||
<div className="genre-filter-tagbox">
|
||||
{selected.map(g => (
|
||||
<span key={g} className="genre-filter-chip">
|
||||
{g}
|
||||
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="genre-filter-popover"
|
||||
style={popStyle}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="genre-filter-popover__search">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('common.filterSearchGenres')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && filteredGenres.length > 0) {
|
||||
toggle(filteredGenres[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="genre-filter-input"
|
||||
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
|
||||
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
|
||||
remove(selected[selected.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{dropdownOpen && filteredOptions.length > 0 && (
|
||||
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
|
||||
{filteredOptions.slice(0, 60).map(g => (
|
||||
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
|
||||
{g}
|
||||
<div className="genre-filter-popover__list">
|
||||
{filteredGenres.length === 0 ? (
|
||||
<div className="genre-filter-popover__empty">
|
||||
{t('common.filterNoGenres')}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
filteredGenres.map(g => {
|
||||
const isSel = selectedSet.has(g);
|
||||
return (
|
||||
<div
|
||||
key={g}
|
||||
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
|
||||
onClick={() => toggle(g)}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
>
|
||||
<span className="genre-filter-popover__check">
|
||||
{isSel && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{g}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
|
||||
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
|
||||
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
|
||||
<X size={13} />
|
||||
{t('common.filterClear')}
|
||||
</button>
|
||||
{count > 0 && (
|
||||
<div className="genre-filter-popover__footer">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clear}
|
||||
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.filterClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useConfirmModalStore } from '../store/confirmModalStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
/**
|
||||
* App-level singleton renderer for the global confirm modal. Mount once
|
||||
* in App.tsx; any code path can then call
|
||||
* `useConfirmModalStore.getState().request(...)` and await the user's decision.
|
||||
*/
|
||||
export default function GlobalConfirmModal() {
|
||||
const { isOpen, title, message, confirmLabel, cancelLabel, danger, confirm, cancel } =
|
||||
useConfirmModalStore();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
confirmLabel={confirmLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
danger={danger}
|
||||
onConfirm={confirm}
|
||||
onCancel={cancelLabel ? cancel : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+11
-4
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const windowHidden = useWindowVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
|
||||
const startTimer = useCallback((len: number) => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (len <= 1) return;
|
||||
timerRef.current = null;
|
||||
if (len <= 1 || windowHidden) return;
|
||||
timerRef.current = setInterval(() => {
|
||||
if (document.hidden || window.__psyHidden) return;
|
||||
setActiveIdx(prev => (prev + 1) % len);
|
||||
}, INTERVAL_MS);
|
||||
}, []);
|
||||
}, [windowHidden]);
|
||||
|
||||
useEffect(() => {
|
||||
startTimer(albums.length);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
}, [albums.length, startTimer]);
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, X, Inbox } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
approveOrbitSuggestion,
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import {
|
||||
getSong,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
|
||||
/**
|
||||
* Host-only approval strip. Renders directly below the OrbitQueueHead
|
||||
* when `autoApprove === false` and at least one guest suggestion is
|
||||
* waiting. Shows each pending track with Approve / Decline controls.
|
||||
*
|
||||
* Only rendered by the host-side render path (QueuePanel); guests never
|
||||
* see this section — they watch their own pending list.
|
||||
*/
|
||||
export default function HostApprovalQueue() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys);
|
||||
const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys);
|
||||
|
||||
const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS;
|
||||
const autoApproveOff = settings.autoApprove === false;
|
||||
|
||||
// Pending = everything in the session's suggestion history that isn't
|
||||
// host-authored, isn't already merged, and hasn't been declined.
|
||||
const pendingItems = useMemo(() => {
|
||||
if (!state) return [];
|
||||
const mergedSet = new Set(mergedKeys);
|
||||
const declinedSet = new Set(declinedKeys);
|
||||
return state.queue.filter(q =>
|
||||
q.addedBy !== state.host
|
||||
&& !mergedSet.has(suggestionKey(q))
|
||||
&& !declinedSet.has(suggestionKey(q))
|
||||
);
|
||||
}, [state, mergedKeys, declinedKeys]);
|
||||
|
||||
// Track-metadata cache (title/artist/cover) for the pending items.
|
||||
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
|
||||
const wantedKey = useMemo(
|
||||
() => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'),
|
||||
[pendingItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
const wanted = wantedKey ? wantedKey.split('|') : [];
|
||||
const missing = wanted.filter(id => id && !songs[id]);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
setSongs(prev => {
|
||||
const next = { ...prev };
|
||||
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="host-approval">
|
||||
<div className="host-approval__head">
|
||||
<Inbox size={12} />
|
||||
<span>{t('orbit.approvalTitle')}</span>
|
||||
<span className="host-approval__count">{pendingItems.length}</span>
|
||||
</div>
|
||||
<div className="host-approval__list">
|
||||
{pendingItems.map(q => {
|
||||
const song = songs[q.trackId];
|
||||
const key = suggestionKey(q);
|
||||
return (
|
||||
<div key={key} className="host-approval__item">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
alt=""
|
||||
className="host-approval__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="host-approval__cover host-approval__cover--ph" />
|
||||
)}
|
||||
<div className="host-approval__info">
|
||||
<div className="host-approval__title">{song?.title ?? '…'}</div>
|
||||
<div className="host-approval__artist">{song?.artist ?? ''}</div>
|
||||
<div className="host-approval__submitter">
|
||||
{t('orbit.approvalFrom', { user: q.addedBy })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="host-approval__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--approve"
|
||||
onClick={() => { void approveOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalAccept')}
|
||||
aria-label={t('orbit.approvalAccept')}
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--decline"
|
||||
onClick={() => { declineOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalDecline')}
|
||||
aria-label={t('orbit.approvalDecline')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function LastfmIndicator() {
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -22,7 +24,11 @@ export default function LiveSearch() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
|
||||
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
|
||||
const ctxType = usePlayerStore(state => state.contextMenu.type);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
@@ -44,14 +50,19 @@ export default function LiveSearch() {
|
||||
|
||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||
|
||||
// Close on click outside
|
||||
// Close on click outside — but stay open while a song context menu is up.
|
||||
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
|
||||
// dropdown, so any mousedown — including a second right-click on another
|
||||
// row — would otherwise hit the backdrop and trip this handler, yanking the
|
||||
// dropdown closed mid-interaction.
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ctxIsOpen) return;
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
}, [ctxIsOpen]);
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
@@ -60,7 +71,9 @@ export default function LiveSearch() {
|
||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||
playTrack(songToTrack(s));
|
||||
const track = songToTrack(s);
|
||||
enqueue([track]);
|
||||
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
|
||||
setOpen(false); setQuery('');
|
||||
}}))),
|
||||
] : [];
|
||||
@@ -144,9 +157,14 @@ export default function LiveSearch() {
|
||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||
{results.artists.map(a => {
|
||||
const i = idx++;
|
||||
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, a, 'artist');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
@@ -161,12 +179,22 @@ export default function LiveSearch() {
|
||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||
{results.albums.map(a => {
|
||||
const i = idx++;
|
||||
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, a, 'album');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
<CachedImage
|
||||
className="search-result-thumb"
|
||||
src={buildCoverArtUrl(a.coverArt, 40)}
|
||||
cacheKey={coverArtCacheKey(a.coverArt, 40)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
@@ -185,12 +213,21 @@ export default function LiveSearch() {
|
||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||
{results.songs.map(s => {
|
||||
const i = idx++;
|
||||
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
|
||||
return (
|
||||
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
playTrack(songToTrack(s));
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
||||
onClick={() => {
|
||||
const track = songToTrack(s);
|
||||
enqueue([track]);
|
||||
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Keep the dropdown open — context menu portal renders above it,
|
||||
// and closing here would yank the list out from under the user.
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
@@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const springRef = useRef<SpringScroller | null>(null);
|
||||
const scrollerRef = useRef<EaseScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevActive = useRef({ line: -1, word: -1 });
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Attach/detach SpringScroller when the pane mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
springRef.current?.stop();
|
||||
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
|
||||
scrollerRef.current?.stop();
|
||||
scrollerRef.current = el ? new EaseScroller(el) : null;
|
||||
}, []);
|
||||
|
||||
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
|
||||
const handleUserScroll = useCallback(() => {
|
||||
springRef.current?.stop();
|
||||
scrollerRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
}, []);
|
||||
|
||||
// Scroll active line into view.
|
||||
// Apple style: spring-animate to ~35% from top.
|
||||
// Classic style: native scrollIntoView center.
|
||||
const scrollToLine = useCallback((el: HTMLDivElement) => {
|
||||
if (isUserScroll.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (sidebarLyricsStyle === 'apple' && springRef.current) {
|
||||
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
||||
if (sidebarLyricsStyle === 'apple' && scrollerRef.current) {
|
||||
scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
@@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
prevActive.current = { line: -1, word: -1 };
|
||||
springRef.current?.jump(0);
|
||||
scrollerRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
|
||||
import { star, unstar } from '../api/subsonic';
|
||||
import type { MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
track: MiniTrackInfo;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim queue-item context menu for the mini player. The mini lives in its
|
||||
* own webview, so all queue mutations forward to the main window via Tauri
|
||||
* events; only the favorite call hits Subsonic directly because it has no
|
||||
* cross-window state to keep in sync (next mini:sync from main reflects the
|
||||
* new starred flag).
|
||||
*/
|
||||
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [starred, setStarred] = useState(!!track.starred);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Clamp the menu inside the mini window's viewport (it pops near the
|
||||
// cursor and would otherwise overflow at the right/bottom edges of the
|
||||
// small window).
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const left = Math.min(x, Math.max(4, vw - r.width - 4));
|
||||
const top = Math.min(y, Math.max(4, vh - r.height - 4));
|
||||
setPos({ left, top });
|
||||
}, [x, y]);
|
||||
|
||||
// Dismiss on outside click + Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const run = (fn: () => void | Promise<void>) => {
|
||||
Promise.resolve(fn()).finally(onClose);
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
const next = !starred;
|
||||
setStarred(next);
|
||||
try {
|
||||
if (next) await star(track.id, 'song');
|
||||
else await unstar(track.id, 'song');
|
||||
} catch {
|
||||
setStarred(!next);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="context-menu mini-context-menu"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => run(() => emit('mini:remove', { index }))}
|
||||
>
|
||||
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{track.albumId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
|
||||
>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{track.artistId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
|
||||
>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => run(toggleStar)}>
|
||||
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
|
||||
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
|
||||
>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, MoveRight } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import MiniContextMenu from './MiniContextMenu';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||
|
||||
const COLLAPSED_SIZE = { w: 340, h: 260 };
|
||||
const EXPANDED_SIZE = { w: 340, h: 500 };
|
||||
// Minimum window dimensions per state. When the queue is open the floor must
|
||||
// keep at least two queue rows visible; a stricter min would let the user
|
||||
// collapse the queue area to nothing while it's still toggled on.
|
||||
const COLLAPSED_MIN = { w: 320, h: 240 };
|
||||
const EXPANDED_MIN = { w: 320, h: 340 };
|
||||
|
||||
// Persist the expanded-window height so reopening the queue restores the
|
||||
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
||||
const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
|
||||
function readStoredExpandedHeight(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(EXPANDED_H_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
|
||||
}
|
||||
} catch {}
|
||||
return EXPANDED_SIZE.h;
|
||||
}
|
||||
|
||||
// Persist whether the queue panel was open so the next launch restores
|
||||
// the same state. Same scope as the height: localStorage of the mini
|
||||
// webview (shared across mini sessions, separate from the main store).
|
||||
const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
|
||||
function readQueueOpen(): boolean {
|
||||
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function toMini(t: any): MiniTrackInfo {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
coverArt: t.coverArt,
|
||||
duration: t.duration,
|
||||
starred: !!t.starred,
|
||||
year: t.year,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate from the persisted playerStore so initial paint shows real content
|
||||
* instead of "—" while we wait for the mini:sync event from the main window.
|
||||
* The persisted state covers the cold-start window (webview boot + bundle).
|
||||
*/
|
||||
function initialSnapshot(): MiniSyncPayload {
|
||||
try {
|
||||
const s = usePlayerStore.getState();
|
||||
return {
|
||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||
queue: (s.queue ?? []).map(toMini),
|
||||
queueIndex: s.queueIndex ?? 0,
|
||||
isPlaying: s.isPlaying,
|
||||
volume: s.volume ?? 1,
|
||||
gaplessEnabled: false,
|
||||
crossfadeEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
isMobile: false,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
track: null, queue: [], queueIndex: 0, isPlaying: false,
|
||||
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
|
||||
infiniteQueueEnabled: false, isMobile: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ProgressPayload {
|
||||
current_time: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function MiniPlayer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(() => {
|
||||
const initial = initialSnapshot();
|
||||
return initial.track?.duration ?? 0;
|
||||
});
|
||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
||||
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
|
||||
const [volumeOpen, setVolumeOpen] = useState(false);
|
||||
const ticker = useRef<number | null>(null);
|
||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||
const volumeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const volumePopRef = useRef<HTMLDivElement>(null);
|
||||
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
|
||||
const hiddenRef = useRef(false);
|
||||
const isHidden = useWindowVisibility();
|
||||
useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]);
|
||||
|
||||
// ── PsyDnD reorder ──
|
||||
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
|
||||
// on the queue computes a drop indicator, psy-drop emits mini:reorder back
|
||||
// to main where the source-of-truth store lives.
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
const isReorderDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
|
||||
// ── Context menu state ──
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
|
||||
|
||||
// Announce to main window that we're mounted; it replies with a snapshot.
|
||||
// Also re-announce on window focus: on Windows the mini is pre-created at
|
||||
// app startup so the mount-time emit can race past main's bridge before
|
||||
// it has attached its listener. Re-emitting on focus means every actual
|
||||
// open of the mini (user clicks the player-bar icon) triggers a fresh
|
||||
// sync regardless of startup ordering.
|
||||
useEffect(() => {
|
||||
emit('mini:ready', {}).catch(() => {});
|
||||
const onFocus = () => { emit('mini:ready', {}).catch(() => {}); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, []);
|
||||
|
||||
// Mini is a separate WebKitGTK webview: Rust applies smooth-wheel per window.
|
||||
// Re-send after auth persist hydrates so preloaded/hidden mini matches Settings.
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
const apply = () => {
|
||||
invoke('set_linux_webkit_smooth_scrolling', {
|
||||
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
|
||||
}).catch(() => {});
|
||||
};
|
||||
apply();
|
||||
return useAuthStore.persist.onFinishHydration(() => {
|
||||
apply();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore the expanded window size on initial mount when the queue was
|
||||
// open at the previous app close. Rust always builds the window at the
|
||||
// collapsed size; without this we'd render queueOpen=true into a 180 px
|
||||
// window. Brief jump from collapsed to expanded is unavoidable since
|
||||
// localStorage only lives in JS.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
invoke('resize_mini_player', {
|
||||
width: EXPANDED_SIZE.w,
|
||||
height: readStoredExpandedHeight(),
|
||||
minWidth: EXPANDED_MIN.w,
|
||||
minHeight: EXPANDED_MIN.h,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Re-apply pin state on mount and whenever the window regains focus.
|
||||
// After a Hide → Show cycle (which is what `open_mini_player` does on
|
||||
// re-toggle) the WM often drops the always-on-top constraint silently;
|
||||
// re-asserting it here means the user no longer has to click the pin
|
||||
// button twice to make it stick.
|
||||
useEffect(() => {
|
||||
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
|
||||
const reapply = () => {
|
||||
if (alwaysOnTop) {
|
||||
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', reapply);
|
||||
return () => window.removeEventListener('focus', reapply);
|
||||
}, [alwaysOnTop]);
|
||||
|
||||
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
|
||||
// Also honour the user-configured 'open-mini-player' shortcut so the
|
||||
// same chord that opens the mini from main also closes it from here.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
const tag = tgt?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return;
|
||||
|
||||
const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player'];
|
||||
if (matchInAppBinding(e, openMiniBinding)) {
|
||||
e.preventDefault();
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyZ' || e.key?.toLowerCase() === 'z')) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
emit('mini:redo-queue', {}).catch(() => {});
|
||||
} else {
|
||||
emit('mini:undo-queue', {}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
emit('mini:control', 'toggle').catch(() => {});
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
emit('mini:control', 'next').catch(() => {});
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
emit('mini:control', 'prev').catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
// Subscribe to state + progress from the main window / Rust.
|
||||
useEffect(() => {
|
||||
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
|
||||
setState(e.payload);
|
||||
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
|
||||
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
|
||||
});
|
||||
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
|
||||
if (hiddenRef.current || window.__psyHidden) return;
|
||||
setCurrentTime(e.payload.current_time);
|
||||
if (e.payload.duration > 0) setDuration(e.payload.duration);
|
||||
});
|
||||
const unEnded = listen('audio:ended', () => setCurrentTime(0));
|
||||
return () => {
|
||||
unSync.then(fn => fn()).catch(() => {});
|
||||
unProgress.then(fn => fn()).catch(() => {});
|
||||
unEnded.then(fn => fn()).catch(() => {});
|
||||
if (ticker.current) window.clearInterval(ticker.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
|
||||
|
||||
const handleVolumeChange = (v: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
setVolumeState(clamped);
|
||||
emit('mini:set-volume', { value: clamped }).catch(() => {});
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
handleVolumeChange(volume === 0 ? 1 : 0);
|
||||
};
|
||||
|
||||
// Position the portaled volume popover relative to its trigger button.
|
||||
// Auto-flip above when there is not enough room below (mini window is short).
|
||||
const updateVolumePopStyle = () => {
|
||||
if (!volumeBtnRef.current) return;
|
||||
const rect = volumeBtnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const POP_W = 40;
|
||||
const POP_H = 150;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left + rect.width / 2 - POP_W / 2, 6),
|
||||
window.innerWidth - POP_W - 6,
|
||||
);
|
||||
setVolumePopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: POP_W,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!volumeOpen) return;
|
||||
updateVolumePopStyle();
|
||||
}, [volumeOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!volumeOpen) return;
|
||||
const onReposition = () => updateVolumePopStyle();
|
||||
window.addEventListener('resize', onReposition);
|
||||
window.addEventListener('scroll', onReposition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
};
|
||||
}, [volumeOpen]);
|
||||
|
||||
// Close the volume popover on outside click / Escape. The popover is now
|
||||
// portaled, so check both the trigger button and the popover ref.
|
||||
useEffect(() => {
|
||||
if (!volumeOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
!volumeBtnRef.current?.contains(target) &&
|
||||
!volumePopRef.current?.contains(target)
|
||||
) setVolumeOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setVolumeOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [volumeOpen]);
|
||||
|
||||
const toggleOnTop = async () => {
|
||||
const next = !alwaysOnTop;
|
||||
setAlwaysOnTop(next);
|
||||
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
|
||||
};
|
||||
|
||||
const closeMini = async () => {
|
||||
try { await invoke('close_mini_player'); } catch {}
|
||||
};
|
||||
|
||||
const showMain = () => invoke('show_main_window').catch(() => {});
|
||||
|
||||
const toggleQueue = async () => {
|
||||
const next = !queueOpen;
|
||||
// Capture the current expanded height before collapsing so the next
|
||||
// open restores it. Read window.innerHeight directly — it matches the
|
||||
// logical inner size that resize_mini_player set previously.
|
||||
if (!next) {
|
||||
const h = Math.round(window.innerHeight);
|
||||
if (h >= EXPANDED_MIN.h) {
|
||||
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
|
||||
}
|
||||
}
|
||||
setQueueOpen(next);
|
||||
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
|
||||
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
|
||||
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
|
||||
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
|
||||
try {
|
||||
await invoke('resize_mini_player', {
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
minWidth: min.w,
|
||||
minHeight: min.h,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
|
||||
|
||||
// Listen for psy-drop on the queue. Only handles `queue_reorder` payloads
|
||||
// since the mini player has no external drag sources. `queueOpen` must be
|
||||
// in deps because the wrap (and thus queueScrollRef.current) only mounts
|
||||
// when the queue is expanded — without it the ref is null on first run
|
||||
// and the listener never attaches.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
const el = queueScrollRef.current;
|
||||
if (!el) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
const tgt = dropTargetRef.current;
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
if (parsed.type !== 'queue_reorder') return;
|
||||
const fromIdx = parsed.index as number;
|
||||
psyDragFromIdxRef.current = null;
|
||||
const queueLen = usePlayerStore.getState().queue.length || state.queue.length;
|
||||
const insertIdx = tgt
|
||||
? (tgt.before ? tgt.idx : tgt.idx + 1)
|
||||
: queueLen;
|
||||
if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return;
|
||||
// Adjust target index if removing the source first shifts later items.
|
||||
const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
|
||||
if (fromIdx === adjusted) return;
|
||||
emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {});
|
||||
};
|
||||
el.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [queueOpen, state.queue.length]);
|
||||
|
||||
// Auto-scroll the current track into view when the queue expands.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
requestAnimationFrame(() => {
|
||||
queueScrollRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
});
|
||||
}, [queueOpen, state.queueIndex]);
|
||||
|
||||
const { track, isPlaying } = state;
|
||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mini-player-shell">
|
||||
<div
|
||||
className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
|
||||
{...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
|
||||
>
|
||||
{IS_LINUX ? (
|
||||
<span className="mini-player__titlebar-title" data-tauri-drag-region>
|
||||
{track?.title ?? 'Psysonic Mini'}
|
||||
</span>
|
||||
) : (
|
||||
// macOS/Windows already render a native titlebar with the window
|
||||
// title + close button; we just need a flexible spacer so the
|
||||
// action buttons sit right.
|
||||
<span className="mini-player__titlebar-spacer" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
|
||||
onClick={toggleOnTop}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
||||
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
||||
>
|
||||
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-player__titlebar-btn"
|
||||
onClick={showMain}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('miniPlayer.openMainWindow')}
|
||||
aria-label={t('miniPlayer.openMainWindow')}
|
||||
>
|
||||
<Maximize2 size={13} />
|
||||
</button>
|
||||
{/* macOS + Windows already provide Close via the native titlebar —
|
||||
skip the duplicate so the in-app titlebar stays minimal. */}
|
||||
{IS_LINUX && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
|
||||
onClick={closeMini}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('miniPlayer.close')}
|
||||
aria-label={t('miniPlayer.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
|
||||
<div className="mini-player__meta">
|
||||
<div className="mini-player__art">
|
||||
{track?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(track.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(track.coverArt, 300)}
|
||||
alt={track.album}
|
||||
/>
|
||||
) : (
|
||||
<div className="mini-player__art-fallback" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mini-player__meta-text" data-tauri-drag-region="false">
|
||||
<div className="mini-player__title" title={track?.title}>
|
||||
{track?.title ?? '—'}
|
||||
</div>
|
||||
{track?.artist && (
|
||||
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
|
||||
)}
|
||||
{track?.album && (
|
||||
<div className="mini-player__album" title={track.album}>{track.album}</div>
|
||||
)}
|
||||
{track?.year && (
|
||||
<div className="mini-player__year">{track.year}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mini-player__toolbar" data-tauri-drag-region="false">
|
||||
<div className="mini-player__volume-wrap">
|
||||
<button
|
||||
ref={volumeBtnRef}
|
||||
type="button"
|
||||
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => setVolumeOpen(v => !v)}
|
||||
onContextMenu={(e) => { e.preventDefault(); toggleMute(); }}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={volume === 0 ? t('player.volume') : `${t('player.volume')} ${Math.round(volume * 100)}%`}
|
||||
aria-label={t('player.volume')}
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
|
||||
</button>
|
||||
{volumeOpen && createPortal(
|
||||
<div
|
||||
ref={volumePopRef}
|
||||
className="mini-player__volume-popover"
|
||||
style={volumePopStyle}
|
||||
data-tauri-drag-region="false"
|
||||
>
|
||||
<span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
|
||||
<div
|
||||
className="mini-player__volume-bar"
|
||||
role="slider"
|
||||
aria-label={t('player.volume')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(volume * 100)}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const setFromY = (clientY: number) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const ratio = 1 - (clientY - rect.top) / rect.height;
|
||||
handleVolumeChange(ratio);
|
||||
};
|
||||
setFromY(e.clientY);
|
||||
const onMove = (me: MouseEvent) => setFromY(me.clientY);
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
handleVolumeChange(volume + (e.deltaY > 0 ? -0.05 : 0.05));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mini-player__volume-bar-fill"
|
||||
style={{ height: `${Math.round(volume * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mini-player__tool"
|
||||
onClick={() => emit('mini:shuffle').catch(() => {})}
|
||||
disabled={state.queue.length < 2}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.shuffle')}
|
||||
aria-label={t('queue.shuffle')}
|
||||
>
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
|
||||
<span className="mini-player__toolbar-sep" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${state.infiniteQueueEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-infinite-queue', { value: !state.infiniteQueueEnabled }).catch(() => {})}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<InfinityIcon size={13} />
|
||||
</button>
|
||||
|
||||
<span className="mini-player__toolbar-sep" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={toggleQueue}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queueOpen && (
|
||||
<OverlayScrollArea
|
||||
viewportRef={queueScrollRef}
|
||||
className="mini-queue-wrap"
|
||||
viewportClassName="mini-queue"
|
||||
measureDeps={[queueOpen, state.queue.length]}
|
||||
railInset="mini"
|
||||
viewportScrollBehaviorAuto={isReorderDrag}
|
||||
onMouseMove={(e) => {
|
||||
if (!isReorderDrag || !queueScrollRef.current) return;
|
||||
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i].getBoundingClientRect();
|
||||
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
const idx = parseInt(items[i].dataset.mqIdx!, 10);
|
||||
const t = { idx, before };
|
||||
dropTargetRef.current = t;
|
||||
setDropTarget(t);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
||||
) : (
|
||||
state.queue.map((t, i) => {
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
||||
dragStyle = { opacity: 0.4 };
|
||||
} else if (isReorderDrag && dropTarget?.idx === i) {
|
||||
dragStyle = dropTarget.before
|
||||
? { boxShadow: 'inset 0 2px 0 var(--accent)' }
|
||||
: { boxShadow: 'inset 0 -2px 0 var(--accent)' };
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${t.id}-${i}`}
|
||||
data-mq-idx={i}
|
||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
|
||||
onClick={() => jumpTo(i)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
// Don't start drag while a click would also be valid —
|
||||
// the threshold check below upgrades to a drag once
|
||||
// the pointer leaves the deadband.
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDragFromIdxRef.current = i;
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
|
||||
me.clientX,
|
||||
me.clientY,
|
||||
);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<span className="mini-queue__num">{i + 1}</span>
|
||||
<div className="mini-queue__meta">
|
||||
<div className="mini-queue__title">{t.title}</div>
|
||||
<div className="mini-queue__artist">{t.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
)}
|
||||
|
||||
<div className="mini-player__bottom" data-tauri-drag-region="false">
|
||||
<div className="mini-player__controls">
|
||||
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mini-player__progress">
|
||||
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
|
||||
<div className="mini-player__progress-track">
|
||||
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mini-player__progress-time">{fmt(duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<MiniContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
track={ctxMenu.track}
|
||||
index={ctxMenu.index}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
const items = sidebarItems
|
||||
.filter(cfg => {
|
||||
if (!cfg?.visible) return false;
|
||||
const item = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!item) return false;
|
||||
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="mobile-more-backdrop" onClick={onClose} />
|
||||
<div className="mobile-more-sheet">
|
||||
<div className="mobile-more-handle" />
|
||||
<div className="mobile-more-grid">
|
||||
{items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="mobile-more-icon"><item.icon size={24} /></span>
|
||||
<span className="mobile-more-label">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{hasOfflineContent && (
|
||||
<NavLink
|
||||
to="/offline"
|
||||
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="mobile-more-icon"><HardDriveDownload size={24} /></span>
|
||||
<span className="mobile-more-label">{t('sidebar.offlineLibrary')}</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="mobile-more-icon"><Settings size={24} /></span>
|
||||
<span className="mobile-more-label">{t('sidebar.settings')}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||||
@@ -162,6 +167,10 @@ export default function MobilePlayerView() {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
@@ -204,14 +213,21 @@ export default function MobilePlayerView() {
|
||||
// Scrubber touch/mouse drag
|
||||
const scrubberRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
|
||||
|
||||
const setPreviewSeek = useCallback((pct: number) => {
|
||||
pendingSeekRef.current = pct;
|
||||
setPreviewProgress(pct);
|
||||
}, []);
|
||||
|
||||
const seekFromX = useCallback((clientX: number) => {
|
||||
const el = scrubberRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seek(pct);
|
||||
}, [seek]);
|
||||
setPreviewSeek(pct);
|
||||
}, [setPreviewSeek]);
|
||||
|
||||
const onScrubStart = useCallback((clientX: number) => {
|
||||
isDragging.current = true;
|
||||
@@ -224,7 +240,14 @@ export default function MobilePlayerView() {
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
seekFromX(clientX);
|
||||
};
|
||||
const onEnd = () => { isDragging.current = false; };
|
||||
const onEnd = () => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
const pending = pendingSeekRef.current;
|
||||
pendingSeekRef.current = null;
|
||||
setPreviewProgress(null);
|
||||
if (pending !== null) seek(pending);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
@@ -238,6 +261,11 @@ export default function MobilePlayerView() {
|
||||
};
|
||||
}, [seekFromX]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingSeekRef.current = null;
|
||||
setPreviewProgress(null);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Drawers
|
||||
const [showQueue, setShowQueue] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
@@ -264,6 +292,11 @@ export default function MobilePlayerView() {
|
||||
const bgStyle: CSSProperties = {
|
||||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||||
};
|
||||
const effectiveProgress = previewProgress ?? progress;
|
||||
const effectiveTime =
|
||||
previewProgress !== null && duration > 0
|
||||
? previewProgress * duration
|
||||
: currentTime;
|
||||
|
||||
return (
|
||||
<div className="mp-view" style={bgStyle}>
|
||||
@@ -328,17 +361,17 @@ export default function MobilePlayerView() {
|
||||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||||
>
|
||||
<div className="mp-scrubber-bg" />
|
||||
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
|
||||
<div className="mp-scrubber-fill" style={{ width: `${effectiveProgress * 100}%` }} />
|
||||
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
|
||||
</div>
|
||||
<div className="mp-scrubber-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
<span>{formatTime(effectiveTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - effectiveTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="mp-controls">
|
||||
<div className="mp-controls" ref={transportAnchorRef}>
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-sm"
|
||||
onClick={() => shuffleQueue()}
|
||||
@@ -349,9 +382,24 @@ export default function MobilePlayerView() {
|
||||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-play"
|
||||
type="button"
|
||||
{...playPauseBind}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{scheduleRemaining != null ? (
|
||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--mobile`}>
|
||||
{scheduleRemaining.mode === 'pause'
|
||||
? <Moon size={13} strokeWidth={2.5} />
|
||||
: <Sunrise size={13} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--mobile">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
@@ -382,6 +430,8 @@ export default function MobilePlayerView() {
|
||||
|
||||
{/* Lyrics Drawer */}
|
||||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||||
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
@@ -28,7 +30,7 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
@@ -63,9 +65,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
|
||||
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
|
||||
const goCategory = (path: string) => { navigate(path); onClose(); };
|
||||
const playSong = (song: SearchResults['songs'][number]) => {
|
||||
const enqueueSong = (song: SearchResults['songs'][number]) => {
|
||||
commit(query);
|
||||
playTrack(songToTrack(song));
|
||||
const track = songToTrack(song);
|
||||
enqueue([track]);
|
||||
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
|
||||
onClose();
|
||||
};
|
||||
const useRecent = (term: string) => {
|
||||
@@ -203,7 +207,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{results!.albums.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||
{a.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
|
||||
<CachedImage
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(a.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(a.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Disc3 size={20} />
|
||||
@@ -223,9 +232,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
||||
{results!.songs.map(s => (
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
|
||||
<CachedImage
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(s.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(s.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Music size={20} />
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { getArtistInfo, getSong, type SubsonicArtistInfo, type SubsonicSong } from '../api/subsonic';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
const TOUR_LIMIT = 5;
|
||||
const BIO_CLAMP_LINES = 4;
|
||||
|
||||
/**
|
||||
* Cross-mount caches keyed by stable IDs so jumping between tracks of the same
|
||||
* artist / album doesn't refire the network call. Cleared on app restart.
|
||||
*/
|
||||
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
|
||||
const songDetailCache = new Map<string, SubsonicSong | null>();
|
||||
|
||||
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const month = d.toLocaleString(undefined, { month: 'short' });
|
||||
const day = String(d.getDate());
|
||||
const weekday = d.toLocaleString(undefined, { weekday: 'short' });
|
||||
const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return { month, day, weekday, time };
|
||||
}
|
||||
|
||||
interface ContributorRow {
|
||||
role: string;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build credits from OpenSubsonic `contributors[]` only. The legacy
|
||||
* artist/albumArtist/composer fallback is intentionally dropped — it
|
||||
* just repeats what's already shown above the tab.
|
||||
*/
|
||||
function buildContributorRows(
|
||||
song: SubsonicSong | null | undefined,
|
||||
mainArtistName: string,
|
||||
): ContributorRow[] {
|
||||
if (!song?.contributors || song.contributors.length === 0) return [];
|
||||
const mainLower = mainArtistName.trim().toLowerCase();
|
||||
const rows = new Map<string, Set<string>>();
|
||||
for (const c of song.contributors) {
|
||||
const role = c.role?.trim();
|
||||
const name = c.artist?.name?.trim();
|
||||
if (!role || !name) continue;
|
||||
const label = c.subRole ? `${role} • ${c.subRole}` : role;
|
||||
let bucket = rows.get(label);
|
||||
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
|
||||
bucket.add(name);
|
||||
}
|
||||
// Drop a row that only restates the main artist under the "artist" role.
|
||||
const out: ContributorRow[] = [];
|
||||
for (const [role, names] of rows.entries()) {
|
||||
const list = Array.from(names);
|
||||
const isMainArtistOnly =
|
||||
role.toLowerCase().startsWith('artist') &&
|
||||
list.length === 1 &&
|
||||
list[0].toLowerCase() === mainLower;
|
||||
if (isMainArtistOnly) continue;
|
||||
out.push({ role, names: list });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function NowPlayingInfo() {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
||||
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
||||
|
||||
const artistName = currentTrack?.artist || '';
|
||||
const artistId = currentTrack?.artistId || '';
|
||||
const songId = currentTrack?.id || '';
|
||||
|
||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
|
||||
artistId ? artistInfoCache.get(artistId) ?? null : null,
|
||||
);
|
||||
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
|
||||
songId ? songDetailCache.get(songId) ?? null : null,
|
||||
);
|
||||
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
|
||||
const [tourLoading, setTourLoading] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [bioOverflows, setBioOverflows] = useState(false);
|
||||
const [showAllTours, setShowAllTours] = useState(false);
|
||||
|
||||
const bioRef = useRef<HTMLParagraphElement | null>(null);
|
||||
|
||||
// Reset per-track UI state when the track changes
|
||||
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
|
||||
|
||||
// Artist bio + image
|
||||
useEffect(() => {
|
||||
if (!artistId) { setArtistInfo(null); return; }
|
||||
const cached = artistInfoCache.get(artistId);
|
||||
if (cached !== undefined) { setArtistInfo(cached); return; }
|
||||
let cancelled = false;
|
||||
getArtistInfo(artistId)
|
||||
.then(info => { if (!cancelled) { artistInfoCache.set(artistId, info ?? null); setArtistInfo(info ?? null); } })
|
||||
.catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [artistId]);
|
||||
|
||||
// Song detail (for OpenSubsonic contributors[])
|
||||
useEffect(() => {
|
||||
if (!songId) { setSongDetail(null); return; }
|
||||
const cached = songDetailCache.get(songId);
|
||||
if (cached !== undefined) { setSongDetail(cached); return; }
|
||||
let cancelled = false;
|
||||
getSong(songId)
|
||||
.then(song => { if (!cancelled) { songDetailCache.set(songId, song ?? null); setSongDetail(song ?? null); } })
|
||||
.catch(() => { if (!cancelled) { songDetailCache.set(songId, null); setSongDetail(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [songId]);
|
||||
|
||||
// Bandsintown — only when opt-in toggle is on
|
||||
useEffect(() => {
|
||||
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
||||
let cancelled = false;
|
||||
setTourLoading(true);
|
||||
fetchBandsintownEvents(artistName)
|
||||
.then(events => { if (!cancelled) setTourEvents(events); })
|
||||
.finally(() => { if (!cancelled) setTourLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [enableBandsintown, artistName]);
|
||||
|
||||
// Detect whether the (clamped) bio actually overflows so we hide the toggle
|
||||
// when it would do nothing.
|
||||
const bio = artistInfo?.biography?.trim() || '';
|
||||
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
|
||||
useLayoutEffect(() => {
|
||||
const el = bioRef.current;
|
||||
if (!el) { setBioOverflows(false); return; }
|
||||
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
|
||||
}, [bioClean]);
|
||||
|
||||
const contributorRows = useMemo(
|
||||
() => buildContributorRows(songDetail, artistName),
|
||||
[songDetail, artistName],
|
||||
);
|
||||
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<div className="np-info-empty">
|
||||
{t('nowPlayingInfo.empty', 'Play something to see info')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const heroImage = artistInfo?.largeImageUrl || artistInfo?.mediumImageUrl || '';
|
||||
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
|
||||
|
||||
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
|
||||
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
|
||||
|
||||
return (
|
||||
<div className="np-info">
|
||||
{/* Artist card */}
|
||||
<section className="np-info-section np-info-artist">
|
||||
{heroImage && heroCacheKey && (
|
||||
<div className="np-info-artist-image-wrap">
|
||||
<CachedImage
|
||||
src={heroImage}
|
||||
cacheKey={heroCacheKey}
|
||||
alt={artistName}
|
||||
className="np-info-artist-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="np-info-artist-body">
|
||||
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
|
||||
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
|
||||
{bioClean && (
|
||||
<>
|
||||
<p
|
||||
ref={bioRef}
|
||||
className={`np-info-artist-bio${bioExpanded ? '' : ' is-clamped'}`}
|
||||
style={bioExpanded ? undefined : { WebkitLineClamp: BIO_CLAMP_LINES }}
|
||||
>
|
||||
{bioClean}
|
||||
</p>
|
||||
{(bioOverflows || bioExpanded) && (
|
||||
<button
|
||||
type="button"
|
||||
className="np-info-link-btn"
|
||||
onClick={() => setBioExpanded(v => !v)}
|
||||
>
|
||||
{bioExpanded
|
||||
? t('nowPlayingInfo.bioReadLess', 'Show less')
|
||||
: t('nowPlayingInfo.bioReadMore', 'Read more')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Song info / contributors — only when OpenSubsonic provided real credits */}
|
||||
{contributorRows.length > 0 && (
|
||||
<section className="np-info-section">
|
||||
<div className="np-info-section-title">{t('nowPlayingInfo.songInfo', 'Song info')}</div>
|
||||
<ul className="np-info-credits">
|
||||
{contributorRows.map(row => (
|
||||
<li key={row.role} className="np-info-credit-row">
|
||||
<span className="np-info-credit-names">{row.names.join(', ')}</span>
|
||||
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tour: prompt to opt-in when off, list when on */}
|
||||
{!enableBandsintown ? (
|
||||
<section className="np-info-section">
|
||||
<div className="np-info-bandsintown-prompt">
|
||||
<div className="np-info-bandsintown-prompt-title">
|
||||
<span>{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}</span>
|
||||
<span
|
||||
className="np-info-bandsintown-prompt-info"
|
||||
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
|
||||
data-tooltip-pos="bottom"
|
||||
data-tooltip-wrap="true"
|
||||
aria-label={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Info size={13} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="np-info-bandsintown-prompt-desc">
|
||||
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="np-info-bandsintown-prompt-btn"
|
||||
onClick={() => setEnableBandsintown(true)}
|
||||
>
|
||||
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="np-info-section">
|
||||
<div className="np-info-section-title">{t('nowPlayingInfo.onTour', 'On tour')}</div>
|
||||
{tourLoading && tourEvents.length === 0 && (
|
||||
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
|
||||
)}
|
||||
{!tourLoading && tourEvents.length === 0 && (
|
||||
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
|
||||
)}
|
||||
{visibleTours.length > 0 && (
|
||||
<ul className="np-info-tour">
|
||||
{visibleTours.map((ev, idx) => {
|
||||
const parts = isoToParts(ev.datetime);
|
||||
const place = [ev.venueCity, ev.venueRegion, ev.venueCountry]
|
||||
.filter(Boolean).join(', ');
|
||||
return (
|
||||
<li
|
||||
key={`${ev.datetime}-${ev.venueName}-${idx}`}
|
||||
className="np-info-tour-item"
|
||||
onClick={() => ev.url && shellOpen(ev.url).catch(() => {})}
|
||||
role={ev.url ? 'button' : undefined}
|
||||
tabIndex={ev.url ? 0 : undefined}
|
||||
>
|
||||
{parts && (
|
||||
<div className="np-info-tour-date">
|
||||
<div className="np-info-tour-date-month">{parts.month}</div>
|
||||
<div className="np-info-tour-date-day">{parts.day}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="np-info-tour-meta">
|
||||
<div className="np-info-tour-venue">{ev.venueName || place}</div>
|
||||
<div className="np-info-tour-place">
|
||||
{parts && (
|
||||
<span className="np-info-tour-when">{parts.weekday}, {parts.time}</span>
|
||||
)}
|
||||
{parts && place && <span className="np-info-tour-sep"> • </span>}
|
||||
<span>{place}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="np-info-tour-more"
|
||||
onClick={() => setShowAllTours(v => !v)}
|
||||
>
|
||||
{showAllTours
|
||||
? t('nowPlayingInfo.showLessTours', 'Show less')
|
||||
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hiddenTourCount })}
|
||||
</button>
|
||||
)}
|
||||
<div className="np-info-tour-credit">
|
||||
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, s
|
||||
{showSettingsLink && (
|
||||
<button
|
||||
className="offline-banner-retry"
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'servers' } })}
|
||||
>
|
||||
<Settings size={12} />
|
||||
{t('connection.serverSettings')}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||
|
||||
/**
|
||||
* Modal shown when joining an Orbit session and the user has more than
|
||||
* one account for the target server URL. Lets them pick which account
|
||||
* to switch to before the join flow continues. Mount once in App.tsx —
|
||||
* any caller can invoke it via `useOrbitAccountPickerStore.request(...)`.
|
||||
*/
|
||||
export default function OrbitAccountPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Reset + focus first item each time the picker re-opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelected(0);
|
||||
// Defer focus to the next tick so the DOM has actually mounted.
|
||||
queueMicrotask(() => itemRefs.current[0]?.focus());
|
||||
}, [isOpen]);
|
||||
|
||||
// Move DOM focus with the arrow-key selection so the browser's focus
|
||||
// ring follows, and the currently active button is readable to AT.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
itemRefs.current[selected]?.focus();
|
||||
}, [selected, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { cancel(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s + 1) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = accounts[selected];
|
||||
if (target) pick(target);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, accounts, selected, pick, cancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-account-picker-title"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div className="modal-content orbit-account-picker">
|
||||
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
|
||||
{t('orbit.accountPickerTitle')}
|
||||
</h3>
|
||||
<p className="orbit-account-picker__sub">
|
||||
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
|
||||
</p>
|
||||
<ul className="orbit-account-picker__list" role="listbox">
|
||||
{accounts.map((a, i) => (
|
||||
<li key={a.id} role="option" aria-selected={i === selected}>
|
||||
<button
|
||||
ref={el => { itemRefs.current[i] = el; }}
|
||||
type="button"
|
||||
className={`orbit-account-picker__item${i === selected ? ' is-active' : ''}`}
|
||||
onClick={() => pick(a)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
>
|
||||
<User size={14} />
|
||||
<span className="orbit-account-picker__user">{a.username}</span>
|
||||
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="orbit-account-picker__actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { leaveOrbitSession } from '../utils/orbit';
|
||||
|
||||
/**
|
||||
* Orbit — exit notification modal.
|
||||
*
|
||||
* Shown when:
|
||||
* - `phase === 'ended'` (host closed the session; guest sees it)
|
||||
* - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us)
|
||||
* - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us;
|
||||
* re-join via invite link still works)
|
||||
*
|
||||
* "OK" cleans up the guest-side outbox + resets the local store.
|
||||
*/
|
||||
export default function OrbitExitModal() {
|
||||
const { t } = useTranslation();
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const sessionName = useOrbitStore(s => s.state?.name);
|
||||
const hostName = useOrbitStore(s => s.state?.host);
|
||||
|
||||
const isEnded = phase === 'ended';
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
const isRemoved = phase === 'error' && errorMessage === 'removed';
|
||||
const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout';
|
||||
const isOpen = isEnded || isKicked || isRemoved || isHostTimeout;
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
if (role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Modal is informational with a single action — Enter / Escape both fire OK.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = isKicked
|
||||
? t('orbit.exitKickedTitle')
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedTitle')
|
||||
: isHostTimeout
|
||||
? t('orbit.exitHostTimeoutTitle')
|
||||
: t('orbit.exitEndedTitle');
|
||||
const body = isKicked
|
||||
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: isHostTimeout
|
||||
? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-exit-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-exit-title"
|
||||
>
|
||||
<div className="modal-content orbit-exit-modal">
|
||||
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
||||
<p className="orbit-exit-modal__body">{body}</p>
|
||||
<div className="orbit-exit-modal__actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onOk} autoFocus>{t('orbit.exitOk')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
getSong,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
|
||||
/**
|
||||
* Orbit — guest-side queue view.
|
||||
*
|
||||
* Rendered in place of the normal QueuePanel contents while `role === 'guest'`.
|
||||
* Read-only: shows the host's current track + the host's actual upcoming
|
||||
* play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No
|
||||
* reorder / remove / save — those belong to the host.
|
||||
*
|
||||
* Track metadata is resolved lazily via `getSong` and cached locally so the
|
||||
* list doesn't flicker while the 2.5 s state tick refreshes the snapshot.
|
||||
*/
|
||||
export default function OrbitGuestQueue() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const pending = useOrbitStore(s => s.pendingSuggestions);
|
||||
const queueItems = state?.playQueue ?? [];
|
||||
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
|
||||
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
|
||||
const currentTrack = state?.currentTrack ?? null;
|
||||
|
||||
// Local song cache — keyed by trackId. Survives parent re-renders triggered
|
||||
// by the store tick so list rows don't remount and recomputed URLs don't
|
||||
// kick off duplicate `getSong` calls.
|
||||
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
|
||||
|
||||
// Track IDs we need but don't yet have. String-joined so useEffect deps
|
||||
// stay stable across identical queue snapshots (e.g. reshuffle).
|
||||
const wantedKey = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
if (currentTrack) ids.push(currentTrack.trackId);
|
||||
queueItems.forEach(q => ids.push(q.trackId));
|
||||
pending.forEach(id => ids.push(id));
|
||||
return Array.from(new Set(ids)).sort().join('|');
|
||||
}, [currentTrack, queueItems, pending]);
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = wantedKey ? wantedKey.split('|') : [];
|
||||
const missing = wanted.filter(id => id && !songs[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
setSongs(prev => {
|
||||
const next = { ...prev };
|
||||
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const currentSong = currentTrack ? songs[currentTrack.trackId] : null;
|
||||
|
||||
return (
|
||||
<div className="orbit-guest-queue">
|
||||
<OrbitQueueHead state={state} />
|
||||
|
||||
{currentTrack && (
|
||||
<div className="orbit-guest-queue__current">
|
||||
<div className="orbit-guest-queue__live-badge">
|
||||
<Radio size={10} /> {t('orbit.guestLive')}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__current-body">
|
||||
{currentSong?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(currentSong.coverArt, 96)}
|
||||
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
|
||||
)}
|
||||
<div className="orbit-guest-queue__info">
|
||||
<div className="orbit-guest-queue__track-title">
|
||||
{currentSong?.title ?? t('orbit.guestLoading')}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__track-artist">
|
||||
{currentSong?.artist ?? ''}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__note">
|
||||
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="orbit-guest-queue__pending">
|
||||
<div className="orbit-guest-queue__section-head orbit-guest-queue__section-head--pending">
|
||||
<Clock size={11} />
|
||||
<span>{t('orbit.guestPendingTitle')}</span>
|
||||
<span className="orbit-guest-queue__count">{pending.length}</span>
|
||||
</div>
|
||||
{pending.map(trackId => {
|
||||
const song = songs[trackId];
|
||||
return (
|
||||
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
|
||||
)}
|
||||
<div className="orbit-guest-queue__info">
|
||||
<div className="orbit-guest-queue__track-title">{song?.title ?? '…'}</div>
|
||||
<div className="orbit-guest-queue__track-artist">{song?.artist ?? ''}</div>
|
||||
<div className="orbit-guest-queue__pending-hint">{t('orbit.guestPendingHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orbit-guest-queue__section-head">
|
||||
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__list">
|
||||
{queueItems.length === 0 && (
|
||||
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
|
||||
)}
|
||||
|
||||
{queueItems.map((q, i) => {
|
||||
const song = songs[q.trackId];
|
||||
const isHostPick = q.addedBy === state.host;
|
||||
return (
|
||||
<div
|
||||
key={`${q.trackId}-${i}`}
|
||||
className="orbit-guest-queue__item"
|
||||
>
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
|
||||
)}
|
||||
<div className="orbit-guest-queue__info">
|
||||
<div className="orbit-guest-queue__track-title">
|
||||
{song?.title ?? '…'}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__track-artist">
|
||||
{song?.artist ?? ''}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__submitter">
|
||||
{isHostPick
|
||||
? t('orbit.queueAddedByHost')
|
||||
: t('orbit.guestSubmitter', { user: q.addedBy })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{truncatedBy > 0 && (
|
||||
<div className="orbit-guest-queue__more">
|
||||
{t('orbit.guestUpNextMore', { count: truncatedBy })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X, Sparkles, Users, Share2, LogIn, MousePointerClick,
|
||||
ListMusic, Inbox, Sliders, LogOut,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHelpModalStore } from '../store/helpModalStore';
|
||||
import SettingsSubSection from './SettingsSubSection';
|
||||
|
||||
/**
|
||||
* Orbit help modal. Rendered once at the app root; triggered from the
|
||||
* launch popover ("How does this work?") and the in-session bar's help
|
||||
* button. 9 accordion sections built on SettingsSubSection; all default
|
||||
* closed so the modal opens compact. Does not touch playback.
|
||||
*/
|
||||
export default function OrbitHelpModal() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, close } = useHelpModalStore();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// Focus the first accordion summary so arrow keys work immediately.
|
||||
// Uses a short setTimeout because the browser re-focuses the clicked
|
||||
// trigger button after the click handler returns — our focus call has
|
||||
// to happen *after* that, otherwise the browser silently overrides it
|
||||
// and the user only gets keyboard nav after pressing Tab first.
|
||||
const id = window.setTimeout(() => {
|
||||
const first = bodyRef.current?.querySelector<HTMLElement>('summary');
|
||||
first?.focus();
|
||||
}, 60);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const summaries = Array.from(
|
||||
bodyRef.current?.querySelectorAll<HTMLElement>('summary') ?? [],
|
||||
);
|
||||
if (summaries.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const idx = summaries.indexOf(current as HTMLElement);
|
||||
e.preventDefault();
|
||||
const next = e.key === 'ArrowDown'
|
||||
? summaries[(idx + 1 + summaries.length) % summaries.length]
|
||||
: summaries[(idx - 1 + summaries.length) % summaries.length];
|
||||
next?.focus();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, close]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostOnlyLabel = t('orbit.helpHostOnly');
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) close(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-help-title"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div className="modal-content orbit-help-modal">
|
||||
<button type="button" className="modal-close" onClick={close} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h3 id="orbit-help-title" className="orbit-help-modal__title">
|
||||
{t('orbit.helpTitle')}
|
||||
</h3>
|
||||
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
|
||||
|
||||
<div className="orbit-help-modal__body" ref={bodyRef}>
|
||||
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
|
||||
<p>{t('orbit.helpSec1Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec2Title')} icon={<Users size={16} />}>
|
||||
<p>{t('orbit.helpSec2Body')}</p>
|
||||
<div className="orbit-help-modal__warn">
|
||||
<strong>{t('orbit.helpSec2WarnHead')}</strong>
|
||||
<span>{t('orbit.helpSec2WarnBody')}</span>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec3Title')} icon={<Share2 size={16} />}>
|
||||
<p>{t('orbit.helpSec3Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec4Title')} icon={<LogIn size={16} />}>
|
||||
<p>{t('orbit.helpSec4Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec5Title')} icon={<MousePointerClick size={16} />}>
|
||||
<p>{t('orbit.helpSec5Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec6Title')} icon={<ListMusic size={16} />}>
|
||||
<p>{t('orbit.helpSec6Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={`${t('orbit.helpSec7Title')} ${hostOnlyLabel}`}
|
||||
icon={<Inbox size={16} />}
|
||||
>
|
||||
<p>{t('orbit.helpSec7Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={`${t('orbit.helpSec8Title')} ${hostOnlyLabel}`}
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<p>{t('orbit.helpSec8Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec9Title')} icon={<LogOut size={16} />}>
|
||||
<p>{t('orbit.helpSec9Body')}</p>
|
||||
</SettingsSubSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, LogIn, ClipboardPaste } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
findSessionPlaylistId,
|
||||
readOrbitState,
|
||||
joinOrbitSession,
|
||||
} from '../utils/orbit';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orbit — manual join modal. Alternative to the Ctrl+V paste shortcut for
|
||||
* users who don't want to (or can't) paste the invite link into the app
|
||||
* directly. Reuses the same parse + preflight pipeline the clipboard
|
||||
* handler uses, so error surfaces stay consistent.
|
||||
*/
|
||||
export default function OrbitJoinModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [link, setLink] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onPaste = async () => {
|
||||
try {
|
||||
const clip = await navigator.clipboard.readText();
|
||||
if (clip) setLink(clip);
|
||||
} catch { /* silent — clipboard perms vary */ }
|
||||
};
|
||||
|
||||
const onJoin = async () => {
|
||||
setError(null);
|
||||
const text = link.trim();
|
||||
if (!text) { setError(t('orbit.joinErrEmpty')); return; }
|
||||
const parsed = parseOrbitShareLink(text);
|
||||
if (!parsed) { setError(t('orbit.joinErrInvalid')); return; }
|
||||
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
||||
const wantUrl = parsed.serverBase.replace(/\/+$/, '');
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
// Auto-switch to the link's server if the user has an account for it.
|
||||
// Multiple candidates → picker modal. switch tears down any lingering
|
||||
// orbit session.
|
||||
if (activeUrl !== wantUrl) {
|
||||
const candidates = useAuthStore.getState().servers
|
||||
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
||||
if (candidates.length === 0) {
|
||||
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
|
||||
return;
|
||||
}
|
||||
const target = candidates.length === 1
|
||||
? candidates[0]
|
||||
: await useOrbitAccountPickerStore.getState().request(candidates);
|
||||
if (!target) { setBusy(false); return; }
|
||||
const switched = await switchActiveServer(target);
|
||||
if (!switched) {
|
||||
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const playlistId = await findSessionPlaylistId(parsed.sid);
|
||||
if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; }
|
||||
const state = await readOrbitState(playlistId);
|
||||
if (!state) { setError(t('orbit.joinErrNotFound')); return; }
|
||||
if (state.ended) { setError(t('orbit.joinErrEnded')); return; }
|
||||
await joinOrbitSession(parsed.sid);
|
||||
showToast(t('orbit.toastJoined'), 2200, 'info');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('orbit.toastJoinFail'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-start-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-join-title"
|
||||
>
|
||||
<div className="modal-content orbit-start-modal">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="orbit-start-modal__hero">
|
||||
<div className="orbit-start-modal__hero-icon">
|
||||
<LogIn size={24} />
|
||||
</div>
|
||||
<h3 id="orbit-join-title" className="orbit-start-modal__title">
|
||||
{t('orbit.joinModalTitle')}
|
||||
</h3>
|
||||
<p className="orbit-start-modal__sub">{t('orbit.joinModalSub')}</p>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-join-link">
|
||||
{t('orbit.joinModalLinkLabel')}
|
||||
</label>
|
||||
<div className="orbit-start-modal__input-row">
|
||||
<input
|
||||
id="orbit-join-link"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={link}
|
||||
onChange={e => { setLink(e.target.value); setError(null); }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }}
|
||||
placeholder={t('orbit.joinModalLinkPlaceholder')}
|
||||
className="orbit-start-modal__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__reshuffle"
|
||||
onClick={onPaste}
|
||||
data-tooltip={t('orbit.joinModalPasteTooltip')}
|
||||
aria-label={t('orbit.joinModalPasteTooltip')}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.joinModalLinkHelper')}</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||
|
||||
<div className="orbit-start-modal__actions">
|
||||
<button type="button" className="btn btn-surface" onClick={onClose}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onJoin}
|
||||
disabled={busy || !link.trim()}
|
||||
>
|
||||
{busy ? t('orbit.joinModalBusy') : t('orbit.joinModalSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface Props {
|
||||
/** Anchor — we position the popover directly below its bottom-right. */
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function joinedFor(fromMs: number, nowMs: number): string {
|
||||
const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000));
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}h${rm.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Close on outside click / Escape — unless a confirm dialog is open
|
||||
// (otherwise outside-clicking the modal would dismiss the popover too,
|
||||
// and re-opening would lose the in-flight confirm context).
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (confirm) return;
|
||||
const t = e.target as Node | null;
|
||||
if (popRef.current?.contains(t)) return;
|
||||
if (anchorRef.current?.contains(t)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (confirm) return;
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose, confirm]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
left: Math.max(8, anchor.left - 100),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (!confirm) return;
|
||||
const { user, mode } = confirm;
|
||||
setConfirm(null);
|
||||
if (mode === 'remove') await removeOrbitParticipant(user);
|
||||
else await kickOrbitParticipant(user);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||
<div className="orbit-participants-pop__head">
|
||||
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
|
||||
</div>
|
||||
|
||||
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
|
||||
<Crown size={13} />
|
||||
<span className="orbit-participants-pop__name">{state.host}</span>
|
||||
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
|
||||
</div>
|
||||
|
||||
{state.participants.length === 0 && (
|
||||
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
|
||||
)}
|
||||
|
||||
{state.participants.map(p => {
|
||||
const isMuted = state.suggestionBlocked?.includes(p.user) ?? false;
|
||||
return (
|
||||
<div key={p.user} className="orbit-participants-pop__row">
|
||||
<User size={13} />
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<div className="orbit-participants-pop__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`orbit-participants-pop__kick${isMuted ? ' is-active' : ''}`}
|
||||
onClick={() => { void setOrbitSuggestionBlocked(p.user, !isMuted); }}
|
||||
data-tooltip={isMuted ? t('orbit.participantsUnmuteTooltip') : t('orbit.participantsMuteTooltip')}
|
||||
aria-label={isMuted
|
||||
? t('orbit.participantsUnmuteAria', { user: p.user })
|
||||
: t('orbit.participantsMuteAria', { user: p.user })}
|
||||
aria-pressed={isMuted}
|
||||
>
|
||||
{isMuted ? <MicOff size={12} /> : <Mic size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
|
||||
data-tooltip={t('orbit.participantsRemoveTooltip')}
|
||||
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
|
||||
data-tooltip={t('orbit.participantsBanTooltip')}
|
||||
aria-label={t('orbit.participantsBanAria', { user: p.user })}
|
||||
>
|
||||
<ShieldOff size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirm}
|
||||
title={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanTitle')
|
||||
: t('orbit.confirmRemoveTitle')}
|
||||
message={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
|
||||
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
|
||||
confirmLabel={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanConfirm')
|
||||
: t('orbit.confirmRemoveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
danger={confirm?.mode === 'ban'}
|
||||
onConfirm={() => { void onConfirm(); }}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Users, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import type { OrbitState } from '../api/orbit';
|
||||
|
||||
interface Props {
|
||||
state: OrbitState;
|
||||
}
|
||||
|
||||
/** Host's state hasn't updated for this long → guest treats them as offline. */
|
||||
const HOST_AWAY_THRESHOLD_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Shared Orbit head strip rendered at the top of the queue for both host
|
||||
* and guest. Shows the session name and a comma-separated list of every
|
||||
* participant (host first, then guests in join order).
|
||||
*
|
||||
* Guest view additionally surfaces host-presence: when the host's tick
|
||||
* hasn't been seen for 15 s we render a subtle "host offline" badge so
|
||||
* the guest knows the stalled playback isn't a local problem.
|
||||
*/
|
||||
export default function OrbitQueueHead({ state }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
// Guest-only clock tick — React wouldn't re-render a stale state blob
|
||||
// on its own, and the presence threshold is time-based.
|
||||
useEffect(() => {
|
||||
if (role !== 'guest') return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [role]);
|
||||
|
||||
const names = [state.host, ...state.participants.map(p => p.user)];
|
||||
const showPresence = role === 'guest' && state.positionAt > 0;
|
||||
const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS;
|
||||
|
||||
return (
|
||||
<div className="orbit-queue-head">
|
||||
<div className="orbit-queue-head__title-row">
|
||||
<h2 className="orbit-queue-head__title">{state.name}</h2>
|
||||
{showPresence && (
|
||||
<span
|
||||
className={`orbit-queue-head__presence orbit-queue-head__presence--${hostAway ? 'away' : 'online'}`}
|
||||
role="status"
|
||||
>
|
||||
{hostAway ? <WifiOff size={11} /> : <Wifi size={11} />}
|
||||
<span>{t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="orbit-queue-head__meta">
|
||||
<Users size={11} />
|
||||
<span className="orbit-queue-head__names">{names.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useHelpModalStore } from '../store/helpModalStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { getSong } from '../api/subsonic';
|
||||
import {
|
||||
endOrbitSession,
|
||||
leaveOrbitSession,
|
||||
computeOrbitDriftMs,
|
||||
effectiveShuffleIntervalMs,
|
||||
} from '../utils/orbit';
|
||||
import { estimateLivePosition } from '../api/orbit';
|
||||
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||
import OrbitExitModal from './OrbitExitModal';
|
||||
import OrbitSettingsPopover from './OrbitSettingsPopover';
|
||||
import OrbitSharePopover from './OrbitSharePopover';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
/**
|
||||
* Orbit — top-strip session indicator.
|
||||
*
|
||||
* Visible whenever the local store reports an active (or just-ended)
|
||||
* session. Shows session name, host, participant count, shuffle countdown,
|
||||
* and role-appropriate action buttons (catch-up for guests, exit for
|
||||
* everyone).
|
||||
*
|
||||
* Deliberately low-chrome: sits above the rest of the app without
|
||||
* reshaping the layout.
|
||||
*/
|
||||
|
||||
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const clamped = Math.max(0, Math.round(ms / 1000));
|
||||
const m = Math.floor(clamped / 60);
|
||||
const s = clamped % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function OrbitSessionBar() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [peopleOpen, setPeopleOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Second-level tick just for the shuffle countdown + drift readout —
|
||||
// the store itself only ticks at 2.5 s which is too coarse for a smooth
|
||||
// countdown.
|
||||
useEffect(() => {
|
||||
if (!state || phase !== 'active') return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state, phase]);
|
||||
|
||||
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
|
||||
const shouldShowBar = !!state && (
|
||||
phase === 'active'
|
||||
|| phase === 'ended'
|
||||
|| (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed'))
|
||||
);
|
||||
if (!shouldShowBar || !state) return (
|
||||
<OrbitExitModal />
|
||||
);
|
||||
|
||||
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
|
||||
|
||||
// Guest-only: detect drift from the host's estimated live position.
|
||||
const guestPlayback = usePlayerStore.getState();
|
||||
const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000);
|
||||
const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId
|
||||
? computeOrbitDriftMs(state, localPositionMs, nowMs)
|
||||
: null;
|
||||
const showCatchUp = role === 'guest'
|
||||
&& state.isPlaying
|
||||
&& state.currentTrack
|
||||
&& (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS);
|
||||
|
||||
const performExit = async () => {
|
||||
try {
|
||||
if (role === 'host') await endOrbitSession();
|
||||
else if (role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onExit = () => {
|
||||
// Active-session exits get a confirm — guests don't want to drop out on
|
||||
// a fat-finger, and the host's X ends the session for everyone, so
|
||||
// accidentally clicking it is even worse. Post-end/kicked dismissals
|
||||
// skip the confirm (the session is already over there).
|
||||
if (phase === 'active' && (role === 'guest' || role === 'host')) {
|
||||
setConfirmLeave(true);
|
||||
return;
|
||||
}
|
||||
void performExit();
|
||||
};
|
||||
|
||||
const onCatchUp = async () => {
|
||||
if (!state.currentTrack) return;
|
||||
const trackId = state.currentTrack.trackId;
|
||||
const targetMs = estimateLivePosition(state, Date.now());
|
||||
const targetSec = Math.max(0, targetMs / 1000);
|
||||
const hostPlaying = state.isPlaying;
|
||||
try {
|
||||
const song = await getSong(trackId);
|
||||
if (!song) return;
|
||||
const track = songToTrack(song);
|
||||
const player = usePlayerStore.getState();
|
||||
const fraction = targetSec / Math.max(1, track.duration);
|
||||
if (player.currentTrack?.id === trackId) {
|
||||
player.seek(fraction);
|
||||
if (hostPlaying && !player.isPlaying) player.resume();
|
||||
else if (!hostPlaying && player.isPlaying) player.pause();
|
||||
} else {
|
||||
// Different track: play + seek on next tick once engine is ready.
|
||||
player.playTrack(track, [track]);
|
||||
window.setTimeout(() => {
|
||||
const p = usePlayerStore.getState();
|
||||
if (p.currentTrack?.id !== trackId) return;
|
||||
p.seek(fraction);
|
||||
if (!hostPlaying && p.isPlaying) p.pause();
|
||||
}, 400);
|
||||
}
|
||||
} catch {
|
||||
// silent — if the track is gone from the host's library, nothing we can do.
|
||||
}
|
||||
};
|
||||
|
||||
const participantCount = state.participants.length + 1; // +1 for the host
|
||||
|
||||
return (
|
||||
<div className="orbit-bar">
|
||||
<div className="orbit-bar__left">
|
||||
<span className="orbit-bar__dot" aria-hidden="true" />
|
||||
<span className="orbit-bar__name">{state.name}</span>
|
||||
<span className="orbit-bar__sep">·</span>
|
||||
<button
|
||||
ref={peopleBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__count"
|
||||
onClick={() => setPeopleOpen(v => !v)}
|
||||
data-tooltip={t('orbit.participantsTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={peopleOpen || undefined}
|
||||
>
|
||||
{participantCount}/{state.maxUsers}
|
||||
</button>
|
||||
<span className="orbit-bar__sep">·</span>
|
||||
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-bar__center">
|
||||
<span className="orbit-bar__shuffle">
|
||||
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
|
||||
<span>{t('orbit.shuffleLabel')}</span>
|
||||
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-bar__right">
|
||||
{role === 'host' && (
|
||||
<button
|
||||
ref={settingsBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
data-tooltip={t('orbit.settingsTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={settingsOpen || undefined}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{role === 'host' && (
|
||||
<button
|
||||
ref={shareBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => setShareOpen(v => !v)}
|
||||
data-tooltip={t('orbit.shareTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={shareOpen || undefined}
|
||||
aria-label={t('orbit.shareTooltip')}
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{showCatchUp && (
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__catchup"
|
||||
onClick={onCatchUp}
|
||||
data-tooltip={t('orbit.catchUpTooltip')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>{t('orbit.catchUpLabel')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => useHelpModalStore.getState().open()}
|
||||
data-tooltip={t('orbit.helpTooltip')}
|
||||
aria-label={t('orbit.helpTooltip')}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__exit"
|
||||
onClick={onExit}
|
||||
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{peopleOpen && (
|
||||
<OrbitParticipantsPopover
|
||||
anchorRef={peopleBtnRef}
|
||||
onClose={() => setPeopleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<OrbitSettingsPopover
|
||||
anchorRef={settingsBtnRef}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{shareOpen && (
|
||||
<OrbitSharePopover
|
||||
anchorRef={shareBtnRef}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<OrbitExitModal />
|
||||
<ConfirmModal
|
||||
open={confirmLeave}
|
||||
title={role === 'host'
|
||||
? t('orbit.confirmEndTitle')
|
||||
: t('orbit.confirmLeaveTitle')}
|
||||
message={role === 'host'
|
||||
? t('orbit.confirmEndBody', { name: state.name })
|
||||
: t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
|
||||
confirmLabel={role === 'host'
|
||||
? t('orbit.confirmEndConfirm')
|
||||
: t('orbit.confirmLeaveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
danger={role === 'host'}
|
||||
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
|
||||
onCancel={() => setConfirmLeave(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Shuffle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit';
|
||||
import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '../api/orbit';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only popover anchored below the settings button in the Orbit bar.
|
||||
* Two toggles; writes are pushed immediately to Navidrome via
|
||||
* `updateOrbitSettings`.
|
||||
*/
|
||||
export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const settings = useOrbitStore(s => s.state?.settings) ?? ORBIT_DEFAULT_SETTINGS;
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-settings-pop" style={style} role="menu">
|
||||
<div className="orbit-settings-pop__head">{t('orbit.settingsTitle')}</div>
|
||||
|
||||
<label className="orbit-settings-pop__row">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingAutoApprove')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoApproveHint')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoApprove}
|
||||
onChange={e => { void updateOrbitSettings({ autoApprove: e.target.checked }); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="orbit-settings-pop__row">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingAutoShuffle')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoShuffleHint')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoShuffle}
|
||||
onChange={e => { void updateOrbitSettings({ autoShuffle: e.target.checked }); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="orbit-settings-pop__row orbit-settings-pop__row--stacked">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingShuffleInterval')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingShuffleIntervalHint')}</div>
|
||||
</div>
|
||||
<div
|
||||
className="orbit-settings-pop__preset-group"
|
||||
role="radiogroup"
|
||||
aria-label={t('orbit.settingShuffleInterval')}
|
||||
>
|
||||
{ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => {
|
||||
const active = (settings.shuffleIntervalMin ?? 15) === min;
|
||||
return (
|
||||
<button
|
||||
key={min}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`orbit-settings-pop__preset${active ? ' is-active' : ''}`}
|
||||
disabled={!settings.autoShuffle}
|
||||
onClick={() => {
|
||||
void updateOrbitSettings({ shuffleIntervalMin: min as OrbitShuffleIntervalMin });
|
||||
}}
|
||||
>
|
||||
{t('orbit.settingShuffleIntervalValue', { count: min })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-settings-pop__action"
|
||||
onClick={() => {
|
||||
void triggerOrbitShuffleNow();
|
||||
showToast(t('orbit.toastShuffled'), 2200, 'info');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Shuffle size={13} />
|
||||
<span>{t('orbit.settingShuffleNow')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildOrbitShareLink } from '../utils/orbit';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only popover anchored below the share button in the Orbit bar.
|
||||
* Surfaces the session invite link with a copy affordance. Lives on its
|
||||
* own so the participants popover can stay focused on participants.
|
||||
*/
|
||||
export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shareLink = sessionId
|
||||
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!shareLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
if (!shareLink) return null;
|
||||
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
|
||||
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
|
||||
<div className="orbit-share-pop__row">
|
||||
<code className="orbit-share-pop__link">{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-share-pop__copy"
|
||||
onClick={onCopy}
|
||||
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||
aria-label={t('orbit.ariaCopyLink')}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X, Check, Copy, Orbit as OrbitIcon,
|
||||
Dices, AlertTriangle, Globe2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
startOrbitSession,
|
||||
buildOrbitShareLink,
|
||||
generateSessionId,
|
||||
} from '../utils/orbit';
|
||||
import { randomOrbitSessionName } from '../utils/orbitNames';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { isLanUrl } from '../hooks/useConnectionStatus';
|
||||
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
/**
|
||||
* Orbit — start-session modal.
|
||||
*
|
||||
* One-screen flow: a share-link is shown immediately (built from a
|
||||
* pre-generated session id + a slug derived from the live name). The host
|
||||
* can copy it any time; pressing "Start" creates the session under that
|
||||
* same id and auto-copies the link if it hasn't been copied yet.
|
||||
*/
|
||||
export default function OrbitStartModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [sid] = useState(() => generateSessionId());
|
||||
const [name, setName] = useState(() => randomOrbitSessionName());
|
||||
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
const [clearQueue, setClearQueue] = useState(false);
|
||||
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const serverBase = server?.url ?? '';
|
||||
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
|
||||
const onLan = isLanUrl(serverBase);
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => buildOrbitShareLink(serverBase, sid),
|
||||
[serverBase, sid],
|
||||
);
|
||||
|
||||
const writeLinkToClipboard = async (): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
const ok = await writeLinkToClipboard();
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setHasCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const onStart = async () => {
|
||||
setError(null);
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
|
||||
|
||||
if (!hasCopied) {
|
||||
const ok = await writeLinkToClipboard();
|
||||
if (ok) setHasCopied(true);
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
if (clearQueue) usePlayerStore.getState().clearQueue();
|
||||
await startOrbitSession({ name: trimmed, maxUsers, sid });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName));
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-start-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-start-title"
|
||||
>
|
||||
<div className="modal-content orbit-start-modal">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="orbit-start-modal__hero">
|
||||
<div className="orbit-start-modal__hero-icon">
|
||||
<OrbitIcon size={24} />
|
||||
</div>
|
||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">
|
||||
{t('orbit.heroTitlePrefix')}{' '}
|
||||
<span className="orbit-start-modal__brand">{t('orbit.heroTitleBrand')}</span>
|
||||
</h3>
|
||||
<p className="orbit-start-modal__sub">
|
||||
{heroSubParts[0]}
|
||||
<strong>{serverName}</strong>
|
||||
{heroSubParts[1] ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`orbit-start-modal__tip${onLan ? ' orbit-start-modal__tip--warn' : ''}`}
|
||||
role={onLan ? 'alert' : undefined}
|
||||
>
|
||||
{onLan ? <AlertTriangle size={15} /> : <Globe2 size={15} />}
|
||||
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-name">
|
||||
{t('orbit.labelName')}
|
||||
</label>
|
||||
<div className="orbit-start-modal__input-row">
|
||||
<input
|
||||
id="orbit-name"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setHasCopied(false); }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (busy || !name.trim()) return;
|
||||
e.preventDefault();
|
||||
void onStart();
|
||||
}}
|
||||
placeholder={t('orbit.namePlaceholder')}
|
||||
maxLength={40}
|
||||
className="orbit-start-modal__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__reshuffle"
|
||||
onClick={() => { setName(randomOrbitSessionName()); setHasCopied(false); }}
|
||||
data-tooltip={t('orbit.reshuffleTooltip')}
|
||||
aria-label={t('orbit.reshuffleAria')}
|
||||
>
|
||||
<Dices size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperName')}</div>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-max">
|
||||
{t('orbit.labelMax')}: <strong>{maxUsers}</strong>
|
||||
</label>
|
||||
<input
|
||||
id="orbit-max"
|
||||
type="range"
|
||||
min={1}
|
||||
max={32}
|
||||
value={maxUsers}
|
||||
onChange={e => setMaxUsers(Number(e.target.value))}
|
||||
className="orbit-start-modal__range"
|
||||
/>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__toggle-row">
|
||||
<div className="orbit-start-modal__toggle-text">
|
||||
<div className="orbit-start-modal__label">{t('orbit.labelClearQueue')}</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperClearQueue')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearQueue}
|
||||
onChange={e => setClearQueue(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
|
||||
<div className="orbit-start-modal__link">
|
||||
<code>{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__copy"
|
||||
onClick={onCopy}
|
||||
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||
aria-label={t('orbit.ariaCopyLink')}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperLink')}</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||
|
||||
<div className="orbit-start-modal__actions">
|
||||
<button type="button" className="btn btn-surface" onClick={onClose}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onStart}
|
||||
disabled={busy || !name.trim()}
|
||||
>
|
||||
{busy
|
||||
? t('orbit.btnStarting')
|
||||
: hasCopied ? t('orbit.btnStart') : t('orbit.btnCopyAndStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useHelpModalStore } from '../store/helpModalStore';
|
||||
import OrbitStartModal from './OrbitStartModal';
|
||||
import OrbitJoinModal from './OrbitJoinModal';
|
||||
import OrbitWordmark from './OrbitWordmark';
|
||||
|
||||
/**
|
||||
* Topbar trigger — opens a small launch popover offering three choices:
|
||||
* create a new session, join an existing one via invite link, or open the
|
||||
* Orbit help section. Hidden while a session is already active so we
|
||||
* don't offer entry points while the user's session bar is already live.
|
||||
*/
|
||||
export default function OrbitStartTrigger() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const visible = useAuthStore(s => s.showOrbitTrigger);
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close popover on outside click / Escape.
|
||||
useEffect(() => {
|
||||
if (!popoverOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (btnRef.current?.contains(target)) return;
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [popoverOpen]);
|
||||
|
||||
if (role !== null) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
const anchor = btnRef.current?.getBoundingClientRect();
|
||||
const popoverStyle: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 8,
|
||||
left: anchor.left,
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
|
||||
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
|
||||
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="btn btn-surface orbit-start-trigger"
|
||||
onClick={() => setPopoverOpen(v => !v)}
|
||||
data-tooltip={t('orbit.triggerTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={popoverOpen || undefined}
|
||||
aria-label={t('orbit.triggerLabel')}
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||
>
|
||||
<OrbitIcon size={18} className="orbit-start-trigger__spin" />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
|
||||
<OrbitWordmark height={14} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{popoverOpen && createPortal(
|
||||
<div ref={popRef} className="orbit-launch-pop" style={popoverStyle} role="menu">
|
||||
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
|
||||
<Plus size={14} />
|
||||
<span>{t('orbit.launchCreate')}</span>
|
||||
</button>
|
||||
<button type="button" className="orbit-launch-pop__item" onClick={pickJoin}>
|
||||
<LogIn size={14} />
|
||||
<span>{t('orbit.launchJoin')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-launch-pop__item"
|
||||
onClick={pickHelp}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
<span>{t('orbit.launchHelp')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{startOpen && <OrbitStartModal onClose={() => setStartOpen(false)} />}
|
||||
{joinOpen && <OrbitJoinModal onClose={() => setJoinOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,141 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
|
||||
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
|
||||
|
||||
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
|
||||
|
||||
export type OverlayScrollAreaProps = {
|
||||
children: React.ReactNode;
|
||||
/** Optional handler on the outer wrapper (e.g. mini queue DnD hit-testing). */
|
||||
onMouseMove?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/** Classes on the outer wrapper (e.g. queue-list-wrap, mini-queue-wrap). */
|
||||
className?: string;
|
||||
/** Classes on the scrollable viewport (e.g. queue-list, mini-queue). */
|
||||
viewportClassName?: string;
|
||||
/** Serialized internally — triggers remeasure + ResizeObserver refresh. */
|
||||
measureDeps?: ReadonlyArray<unknown>;
|
||||
/** Vertical inset of the hit rail (align with viewport padding). */
|
||||
railInset?: OverlayScrollRailInset;
|
||||
/** e.g. during native DnD — scroll-behavior: auto on the viewport. */
|
||||
viewportScrollBehaviorAuto?: boolean;
|
||||
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
/** Optional id on the viewport (e.g. main app scroll for route pages). */
|
||||
viewportId?: string;
|
||||
};
|
||||
|
||||
const RAIL_INSET_CLASS: Record<OverlayScrollRailInset, string> = {
|
||||
none: 'overlay-scroll--rail-inset-none',
|
||||
mini: 'overlay-scroll--rail-inset-mini',
|
||||
panel: 'overlay-scroll--rail-inset-panel',
|
||||
};
|
||||
|
||||
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
|
||||
if (ref == null) return;
|
||||
if (typeof ref === 'function') ref(value);
|
||||
else (ref as { current: T | null }).current = value;
|
||||
}
|
||||
|
||||
export default function OverlayScrollArea({
|
||||
children,
|
||||
onMouseMove,
|
||||
className = '',
|
||||
viewportClassName = '',
|
||||
measureDeps = [],
|
||||
railInset = 'none',
|
||||
viewportScrollBehaviorAuto = false,
|
||||
viewportRef: viewportRefProp,
|
||||
viewportId,
|
||||
}: OverlayScrollAreaProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||
|
||||
const recompute = useCallback(() => {
|
||||
const vp = viewportRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
|
||||
const trackH =
|
||||
rail && rail.clientHeight > 0 ? rail.clientHeight : undefined;
|
||||
setMeta(computeOverlayScrollbarThumbMeta(vp, trackH));
|
||||
}, []);
|
||||
|
||||
const measureKey = JSON.stringify(measureDeps ?? []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!meta.visible) return;
|
||||
const vp = viewportRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
|
||||
const th = rail?.clientHeight;
|
||||
if (!vp || !th || th <= 0) return;
|
||||
setMeta((prev) => {
|
||||
const next = computeOverlayScrollbarThumbMeta(vp, th);
|
||||
if (
|
||||
prev.thumbH === next.thumbH &&
|
||||
prev.thumbT === next.thumbT &&
|
||||
prev.visible === next.visible
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [meta.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
recompute();
|
||||
const wrap = wrapRef.current;
|
||||
const onWinResize = () => recompute();
|
||||
window.addEventListener('resize', onWinResize);
|
||||
const ro =
|
||||
typeof ResizeObserver !== 'undefined' && wrap
|
||||
? new ResizeObserver(() => recompute())
|
||||
: null;
|
||||
if (ro && wrap) ro.observe(wrap);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWinResize);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [recompute, measureKey]);
|
||||
|
||||
const setViewportNode = (el: HTMLDivElement | null) => {
|
||||
viewportRef.current = el;
|
||||
assignRef(viewportRefProp, el);
|
||||
};
|
||||
|
||||
const rootClass = [
|
||||
'overlay-scroll',
|
||||
RAIL_INSET_CLASS[railInset],
|
||||
viewportScrollBehaviorAuto ? 'overlay-scroll--viewport-scroll-auto' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
|
||||
<div
|
||||
id={viewportId}
|
||||
ref={setViewportNode}
|
||||
className={viewportClass}
|
||||
onScroll={recompute}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{meta.visible && (
|
||||
<div className="overlay-scroll__rail" aria-hidden>
|
||||
<div
|
||||
className="overlay-scroll__thumb"
|
||||
style={{
|
||||
height: `${meta.thumbH}px`,
|
||||
transform: `translateY(${meta.thumbT}px)`,
|
||||
}}
|
||||
onPointerDown={(ev) => bindOverlayScrollbarThumbDrag(ev, viewportRef.current)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { decodeSharePayloadFromText } from '../utils/shareLink';
|
||||
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
|
||||
import { applySharePastePayload } from '../utils/applySharePaste';
|
||||
import { showToast } from '../utils/toast';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
joinOrbitSession,
|
||||
findSessionPlaylistId,
|
||||
readOrbitState,
|
||||
OrbitJoinError,
|
||||
} from '../utils/orbit';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
||||
'not-found': 'orbit.joinErrNotFound',
|
||||
'ended': 'orbit.joinErrEnded',
|
||||
'full': 'orbit.joinErrFull',
|
||||
'kicked': 'orbit.joinErrKicked',
|
||||
'no-user': 'orbit.joinErrNoUser',
|
||||
'server-error': 'orbit.joinErrServerError',
|
||||
};
|
||||
|
||||
/**
|
||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||
*/
|
||||
export default function PasteClipboardHandler() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const busy = useRef(false);
|
||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
||||
|
||||
// `not-found` and `ended` collapse into a single "link no longer valid"
|
||||
// dialog — from the guest's POV both mean the same thing: the invite
|
||||
// doesn't lead anywhere any more. Other reasons stay as toasts because
|
||||
// they're actionable (full → wait, kicked → talk to host, etc.).
|
||||
const handleJoinError = (reason: string | null, fallback?: string) => {
|
||||
if (reason === 'not-found' || reason === 'ended') {
|
||||
setOrbitInvalid(true);
|
||||
return;
|
||||
}
|
||||
const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null;
|
||||
showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error');
|
||||
};
|
||||
|
||||
const runOrbitJoin = (sid: string) => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
joinOrbitSession(sid)
|
||||
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message);
|
||||
else handleJoinError(null);
|
||||
})
|
||||
.finally(() => { busy.current = false; });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData?.getData('text/plain') ?? '';
|
||||
|
||||
// Orbit share link — handled before library shares.
|
||||
const orbit = parseOrbitShareLink(text.trim());
|
||||
if (orbit) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; }
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
|
||||
(async () => {
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
||||
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
||||
|
||||
// Auto-switch to the link's target server if the user has an
|
||||
// account registered for it. No account → clear error. Multiple
|
||||
// accounts for the same URL → picker lets the user choose. The
|
||||
// switch itself tears down any lingering orbit session (see
|
||||
// switchActiveServer) so the join below starts clean.
|
||||
if (activeUrl !== wantUrl) {
|
||||
const candidates = useAuthStore.getState().servers
|
||||
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
||||
if (candidates.length === 0) {
|
||||
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
|
||||
return;
|
||||
}
|
||||
const target = candidates.length === 1
|
||||
? candidates[0]
|
||||
: await useOrbitAccountPickerStore.getState().request(candidates);
|
||||
if (!target) return; // cancelled
|
||||
const switched = await switchActiveServer(target);
|
||||
if (!switched) {
|
||||
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preview the session state so the confirm dialog can show the
|
||||
// host and session name. Failures surface the same error toasts
|
||||
// the join would, without ever showing the confirm.
|
||||
const playlistId = await findSessionPlaylistId(orbit.sid);
|
||||
if (!playlistId) { handleJoinError('not-found'); return; }
|
||||
const state = await readOrbitState(playlistId);
|
||||
if (!state) { handleJoinError('not-found'); return; }
|
||||
if (state.ended) { handleJoinError('ended'); return; }
|
||||
setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name });
|
||||
})()
|
||||
.catch(() => handleJoinError(null))
|
||||
.finally(() => { busy.current = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
const share = decodeSharePayloadFromText(text);
|
||||
if (share) {
|
||||
if (!isLoggedIn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return;
|
||||
}
|
||||
if (busy.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
busy.current = true;
|
||||
void applySharePastePayload(share, navigate, t).finally(() => {
|
||||
busy.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const invite = decodeServerMagicStringFromText(text);
|
||||
if (!invite) return;
|
||||
if (busy.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
busy.current = true;
|
||||
if (isLoggedIn) {
|
||||
navigate('/settings', { state: { tab: 'server' as const, openAddServerInvite: invite } });
|
||||
} else {
|
||||
navigate('/login', { state: { openAddServerInvite: invite } });
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
busy.current = false;
|
||||
});
|
||||
};
|
||||
document.addEventListener('paste', onPaste, true);
|
||||
return () => document.removeEventListener('paste', onPaste, true);
|
||||
}, [navigate, t, isLoggedIn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmModal
|
||||
open={!!orbitConfirm}
|
||||
title={t('orbit.confirmJoinTitle')}
|
||||
message={t('orbit.confirmJoinBody', {
|
||||
host: orbitConfirm?.host ?? '',
|
||||
name: orbitConfirm?.name ?? '',
|
||||
})}
|
||||
confirmLabel={t('orbit.confirmJoinConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
onConfirm={() => {
|
||||
const sid = orbitConfirm?.sid;
|
||||
setOrbitConfirm(null);
|
||||
if (sid) runOrbitJoin(sid);
|
||||
}}
|
||||
onCancel={() => setOrbitConfirm(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={orbitInvalid}
|
||||
title={t('orbit.invalidLinkTitle')}
|
||||
message={t('orbit.invalidLinkBody')}
|
||||
confirmLabel={t('orbit.exitOk')}
|
||||
onConfirm={() => setOrbitInvalid(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Moon, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TFunction } from 'i18next';
|
||||
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
function formatClockTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** One tap = schedule; custom minutes still covers any duration. */
|
||||
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
|
||||
|
||||
function formatPresetLabel(seconds: number, t: TFunction): string {
|
||||
if (seconds < 60) return t('player.delayFmtSec', { n: seconds });
|
||||
if (seconds < 3600) return t('player.delayFmtMin', { n: seconds / 60 });
|
||||
return t('player.delayFmtHr', { n: seconds / 3600 });
|
||||
}
|
||||
|
||||
function computeAnchoredPanelStyle(anchorEl: HTMLElement): React.CSSProperties {
|
||||
const ar = anchorEl.getBoundingClientRect();
|
||||
const mw = Math.min(360, Math.max(200, window.innerWidth - 32));
|
||||
let left = ar.left + ar.width / 2 - mw / 2;
|
||||
const pad = 12;
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - mw - pad));
|
||||
const gap = 10;
|
||||
return {
|
||||
position: 'fixed',
|
||||
left,
|
||||
bottom: window.innerHeight - ar.top + gap,
|
||||
width: mw,
|
||||
maxWidth: 360,
|
||||
margin: 0,
|
||||
maxHeight: 'min(72vh, calc(100vh - 24px))',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaybackDelayModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** When set, panel is fixed just above this element (transport strip). */
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function PlaybackDelayModal({ open, onClose, anchorRef }: PlaybackDelayModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isPlaying,
|
||||
currentTrack,
|
||||
currentRadio,
|
||||
scheduledPauseAtMs,
|
||||
scheduledResumeAtMs,
|
||||
schedulePauseIn,
|
||||
scheduleResumeIn,
|
||||
clearScheduledPause,
|
||||
clearScheduledResume,
|
||||
} = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
isPlaying: s.isPlaying,
|
||||
currentTrack: s.currentTrack,
|
||||
currentRadio: s.currentRadio,
|
||||
scheduledPauseAtMs: s.scheduledPauseAtMs,
|
||||
scheduledResumeAtMs: s.scheduledResumeAtMs,
|
||||
schedulePauseIn: s.schedulePauseIn,
|
||||
scheduleResumeIn: s.scheduleResumeIn,
|
||||
clearScheduledPause: s.clearScheduledPause,
|
||||
clearScheduledResume: s.clearScheduledResume,
|
||||
})),
|
||||
);
|
||||
|
||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||
const [posTick, setPosTick] = useState(0);
|
||||
const [customMinutes, setCustomMinutes] = useState('');
|
||||
/** Preset-seconds the user is currently hovering — drives the live "Pauses at HH:MM" preview. */
|
||||
const [hoverSeconds, setHoverSeconds] = useState<number | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCustomMinutes('');
|
||||
setHoverSeconds(null);
|
||||
}, [open]);
|
||||
|
||||
// While modal is open, refresh the "now" tick every second so the live
|
||||
// preview clock stays accurate.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setInterval(() => setNowTick(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (scheduledPauseAtMs == null && scheduledResumeAtMs == null) return;
|
||||
const id = window.setInterval(() => setNowTick(Date.now()), 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open, scheduledPauseAtMs, scheduledResumeAtMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !anchorRef) return;
|
||||
const bump = () => setPosTick(x => x + 1);
|
||||
window.addEventListener('resize', bump);
|
||||
window.addEventListener('scroll', bump, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', bump);
|
||||
window.removeEventListener('scroll', bump, true);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const canPauseLater = isPlaying && (!!currentTrack || !!currentRadio);
|
||||
const canStartLater = !isPlaying && (!!currentTrack || !!currentRadio);
|
||||
|
||||
const customSeconds = useMemo(() => {
|
||||
const n = parseFloat(customMinutes.replace(',', '.'));
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.round(n * 60);
|
||||
}, [customMinutes]);
|
||||
|
||||
const applyPause = (sec: number) => {
|
||||
schedulePauseIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const applyStart = (sec: number) => {
|
||||
scheduleResumeIn(sec);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const useAnchor = !!anchorRef;
|
||||
const anchorEl = anchorRef?.current ?? null;
|
||||
void posTick;
|
||||
const anchoredPanelStyle =
|
||||
open && useAnchor && anchorEl ? computeAnchoredPanelStyle(anchorEl) : undefined;
|
||||
|
||||
const heading =
|
||||
canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle');
|
||||
|
||||
// Mode determines icon + colour accent ("mood") of the modal.
|
||||
const mode: 'pause' | 'start' | 'idle' =
|
||||
canPauseLater ? 'pause' : canStartLater ? 'start' : 'idle';
|
||||
const HeadingIcon = mode === 'pause' ? Moon : mode === 'start' ? Sunrise : null;
|
||||
|
||||
// Live preview: seconds that would be applied right now if the user clicked.
|
||||
// Priority: hovered chip → typed custom minutes → nothing.
|
||||
const previewSeconds = hoverSeconds ?? customSeconds;
|
||||
const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null;
|
||||
const previewClock = previewAtMs != null ? formatClockTime(previewAtMs) : null;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
|
||||
const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle;
|
||||
|
||||
const scheduledAt = canPauseLater ? scheduledPauseAtMs : canStartLater ? scheduledResumeAtMs : null;
|
||||
const clearScheduled = canPauseLater ? clearScheduledPause : canStartLater ? clearScheduledResume : null;
|
||||
const cancelLabel = canPauseLater ? t('player.delayCancelPause') : t('player.delayCancelStart');
|
||||
const apply = canPauseLater ? applyPause : applyStart;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="playback-delay-modal-title"
|
||||
style={
|
||||
useAnchor
|
||||
? { alignItems: 'stretch', justifyContent: 'flex-start', padding: 0 }
|
||||
: { alignItems: 'center', paddingTop: 0 }
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`modal-content playback-delay-modal playback-delay-modal--${mode}`}
|
||||
data-pd-mode={mode}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={panelStyle}
|
||||
>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="playback-delay-modal__head">
|
||||
{HeadingIcon && (
|
||||
<span className="playback-delay-modal__icon" aria-hidden="true">
|
||||
<HeadingIcon size={18} />
|
||||
</span>
|
||||
)}
|
||||
<h3 id="playback-delay-modal-title" className="playback-delay-modal__title">
|
||||
{heading}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{(canPauseLater || canStartLater) && (
|
||||
<>
|
||||
{scheduledAt != null && (
|
||||
<div className="playback-delay-section__head playback-delay-section__head--tight">
|
||||
<span className="playback-delay-section__countdown">
|
||||
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)}
|
||||
</span>
|
||||
{clearScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
|
||||
aria-label={cancelLabel}
|
||||
onClick={() => clearScheduled()}
|
||||
>
|
||||
{t('player.delayCancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="playback-delay-chips playback-delay-chips--compact"
|
||||
onMouseLeave={() => setHoverSeconds(null)}
|
||||
>
|
||||
{PRESET_SECONDS.map(sec => (
|
||||
<button
|
||||
key={`pr-${sec}`}
|
||||
type="button"
|
||||
className="playback-delay-chip"
|
||||
onMouseEnter={() => setHoverSeconds(sec)}
|
||||
onFocus={() => setHoverSeconds(sec)}
|
||||
onBlur={() => setHoverSeconds(null)}
|
||||
onClick={() => apply(sec)}
|
||||
>
|
||||
{formatPresetLabel(sec, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="playback-delay-custom playback-delay-custom--inline">
|
||||
<div className="playback-delay-custom__field">
|
||||
<input
|
||||
ref={customInputRef}
|
||||
id="playback-delay-custom-min"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="playback-delay-custom__input"
|
||||
placeholder={t('player.delayCustomPlaceholder')}
|
||||
value={customMinutes}
|
||||
onChange={e => setCustomMinutes(e.target.value)}
|
||||
aria-label={t('player.delayCustomMinutes')}
|
||||
/>
|
||||
<span className="playback-delay-custom__suffix" aria-hidden="true">min</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={customSeconds == null}
|
||||
aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
|
||||
onClick={() => { if (customSeconds != null) apply(customSeconds); }}
|
||||
>
|
||||
{t('player.delayApply')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="playback-delay-preview"
|
||||
aria-live="polite"
|
||||
data-empty={previewClock == null ? 'true' : 'false'}
|
||||
>
|
||||
{previewClock != null && (
|
||||
<>
|
||||
<span className="playback-delay-preview__label">
|
||||
{canPauseLater ? t('player.delayPreviewPause') : t('player.delayPreviewStart')}
|
||||
</span>
|
||||
<span className="playback-delay-preview__time">{previewClock}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canPauseLater && !canStartLater && (
|
||||
<div className="playback-delay-idle">
|
||||
<p className="playback-delay-muted">{t('player.delayInactivePause')}</p>
|
||||
<p className="playback-delay-muted">{t('player.delayInactiveStart')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||
|
||||
export interface PlaybackScheduleBadgeProps {
|
||||
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
|
||||
layoutAnchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Extra class on the portaled ring (e.g. fullscreen sizing). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular progress ring around the play/pause button, portaled to document.body
|
||||
* so it is never clipped by `contain: paint` on the player bar.
|
||||
*
|
||||
* - Accent-coloured SVG stroke with a gradient; depletes as the deadline approaches.
|
||||
* - Colour shifts to a warm warning hue when <10 % of the scheduled time remains.
|
||||
* - The remaining time is rendered _inside_ the button (replaces the
|
||||
* Play/Pause icon) by the consuming view, not here — avoids the floating
|
||||
* pill clipping against the viewport edge.
|
||||
*/
|
||||
export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isPlaying,
|
||||
scheduledPauseAtMs,
|
||||
scheduledPauseStartMs,
|
||||
scheduledResumeAtMs,
|
||||
scheduledResumeStartMs,
|
||||
} = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
isPlaying: s.isPlaying,
|
||||
scheduledPauseAtMs: s.scheduledPauseAtMs,
|
||||
scheduledPauseStartMs: s.scheduledPauseStartMs,
|
||||
scheduledResumeAtMs: s.scheduledResumeAtMs,
|
||||
scheduledResumeStartMs: s.scheduledResumeStartMs,
|
||||
})),
|
||||
);
|
||||
|
||||
// Active timer: pause if playing, resume if paused.
|
||||
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
|
||||
const startMs = isPlaying ? scheduledPauseStartMs : scheduledResumeStartMs;
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
|
||||
const windowHidden = useWindowVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null || windowHidden) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (document.hidden || window.__psyHidden) return;
|
||||
setNowMs(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [deadlineMs, windowHidden]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (deadlineMs == null || windowHidden) return;
|
||||
const el = layoutAnchorRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setAnchorRect({
|
||||
left: r.left + r.width / 2,
|
||||
top: r.top + r.height / 2,
|
||||
size: Math.max(r.width, r.height),
|
||||
});
|
||||
};
|
||||
sync();
|
||||
window.addEventListener('resize', sync);
|
||||
window.addEventListener('scroll', sync, true);
|
||||
const iv = window.setInterval(sync, 400);
|
||||
return () => {
|
||||
window.removeEventListener('resize', sync);
|
||||
window.removeEventListener('scroll', sync, true);
|
||||
window.clearInterval(iv);
|
||||
};
|
||||
}, [deadlineMs, layoutAnchorRef, windowHidden]);
|
||||
|
||||
if (deadlineMs == null || startMs == null || !anchorRect) return null;
|
||||
|
||||
const totalMs = Math.max(1, deadlineMs - startMs);
|
||||
const remainingMs = Math.max(0, deadlineMs - nowMs);
|
||||
const progress = Math.min(1, Math.max(0, 1 - remainingMs / totalMs)); // 0 → just armed, 1 → fires now
|
||||
const nearEnd = remainingMs / totalMs < 0.1;
|
||||
|
||||
const label = isPlaying && scheduledPauseAtMs != null
|
||||
? `${t('player.delayPauseSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`
|
||||
: `${t('player.delayStartSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`;
|
||||
|
||||
// Ring sits snug around the button; diameter ~1.22× button size for breathing room.
|
||||
const ringSize = Math.round(anchorRect.size * 1.22);
|
||||
const strokeW = Math.max(2.5, ringSize / 28);
|
||||
const r = ringSize / 2 - strokeW / 2;
|
||||
const circ = 2 * Math.PI * r;
|
||||
// Reversed direction so the ring shrinks counter-clockwise from the top.
|
||||
const dashOffset = -circ * progress;
|
||||
|
||||
// Mode selects the gradient tint: pause = lavender, start = peach.
|
||||
const mode: 'pause' | 'start' = isPlaying ? 'pause' : 'start';
|
||||
// Uniqueish gradient id — multiple badges (player bar + fullscreen) can coexist.
|
||||
const gradId = `psy-sched-grad-${mode}`;
|
||||
|
||||
const wrapStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchorRect.left,
|
||||
top: anchorRect.top,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: ringSize,
|
||||
height: ringSize,
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<span
|
||||
className={[
|
||||
'playback-schedule-ring',
|
||||
`playback-schedule-ring--${mode}`,
|
||||
nearEnd ? 'is-warn' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={wrapStyle}
|
||||
aria-label={label}
|
||||
>
|
||||
<svg
|
||||
className="playback-schedule-ring__svg"
|
||||
width={ringSize}
|
||||
height={ringSize}
|
||||
viewBox={`0 0 ${ringSize} ${ringSize}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" className="playback-schedule-ring__grad-a" />
|
||||
<stop offset="100%" className="playback-schedule-ring__grad-b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
className="playback-schedule-ring__track"
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth={strokeW}
|
||||
/>
|
||||
<circle
|
||||
className="playback-schedule-ring__fill"
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={`url(#${gradId})`}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${ringSize / 2} ${ringSize / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+122
-13
@@ -2,11 +2,14 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
|
||||
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
@@ -18,6 +21,10 @@ import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -41,11 +48,28 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render.
|
||||
const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const updateRemaining = () => {
|
||||
if (spanRef.current) {
|
||||
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
|
||||
spanRef.current.textContent = `-${formatTime(remaining)}`;
|
||||
}
|
||||
};
|
||||
updateRemaining();
|
||||
return usePlayerStore.subscribe(updateRemaining);
|
||||
}, [duration]);
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
||||
const premuteVolumeRef = useRef(1);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
@@ -81,6 +105,45 @@ export default function PlayerBar() {
|
||||
setUserRatingOverride: s.setUserRatingOverride,
|
||||
})));
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!floatingPlayerBar) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const sidebar = document.querySelector('.sidebar') as HTMLElement;
|
||||
const queue = document.querySelector('.queue-panel') as HTMLElement;
|
||||
|
||||
const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0;
|
||||
const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0;
|
||||
|
||||
setFloatingStyle({
|
||||
left: leftOffset + 24,
|
||||
right: rightOffset + 24,
|
||||
width: 'auto',
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const observer = new ResizeObserver(updatePosition);
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const queue = document.querySelector('.queue-panel');
|
||||
if (sidebar) observer.observe(sidebar);
|
||||
if (queue) observer.observe(queue);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [floatingPlayerBar]);
|
||||
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
@@ -130,8 +193,14 @@ export default function PlayerBar() {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
||||
const playerBarContent = (
|
||||
<>
|
||||
<footer
|
||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
|
||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||
role="region"
|
||||
aria-label={t('player.regionLabel')}
|
||||
>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
@@ -233,21 +302,32 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="player-buttons">
|
||||
<div className="player-buttons" ref={transportAnchorRef}>
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
type="button"
|
||||
{...playPauseBind}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{scheduleRemaining != null ? (
|
||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
|
||||
{scheduleRemaining.mode === 'pause'
|
||||
? <Moon size={10} strokeWidth={2.5} />
|
||||
: <Sunrise size={10} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
@@ -295,7 +375,18 @@ export default function PlayerBar() {
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
<span
|
||||
className="player-time player-time-toggle"
|
||||
onClick={() => {
|
||||
const newVal = !localShowRemaining;
|
||||
setLocalShowRemaining(newVal);
|
||||
useThemeStore.getState().setShowRemainingTime(newVal);
|
||||
}}
|
||||
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
|
||||
>
|
||||
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTime(duration)}
|
||||
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,6 +401,16 @@ export default function PlayerBar() {
|
||||
<SlidersVertical size={15} />
|
||||
</button>
|
||||
|
||||
{/* Mini Player */}
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => invoke('open_mini_player').catch(() => {})}
|
||||
aria-label="Mini Player"
|
||||
data-tooltip="Mini Player"
|
||||
>
|
||||
<PictureInPicture2 size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
@@ -368,5 +469,13 @@ export default function PlayerBar() {
|
||||
)}
|
||||
|
||||
</footer>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (floatingPlayerBar) {
|
||||
return createPortal(playerBarContent, document.body);
|
||||
}
|
||||
|
||||
return playerBarContent;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+418
-35
@@ -1,17 +1,36 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
|
||||
import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Track,
|
||||
usePlayerStore,
|
||||
songToTrack,
|
||||
registerQueueListScrollTopReader,
|
||||
consumePendingQueueListScrollTop,
|
||||
} from '../store/playerStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
import HostApprovalQueue from './HostApprovalQueue';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { encodeSharePayload } from '../utils/shareLink';
|
||||
import { copyTextToClipboard } from '../utils/serverMagicString';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import NowPlayingInfo from './NowPlayingInfo';
|
||||
import { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -225,8 +244,44 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
|
||||
}
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
if (orbitRole === 'guest') {
|
||||
return (
|
||||
<aside className="queue-panel queue-panel--orbit-guest">
|
||||
<OrbitGuestQueue />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
return <QueuePanelHostOrSolo />;
|
||||
}
|
||||
|
||||
function QueuePanelHostOrSolo() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const orbitState = useOrbitStore(s => s.state);
|
||||
/** trackId → addedBy (host username or guest username) — only populated while
|
||||
* hosting an Orbit session, so the queue rows can surface attribution. */
|
||||
const orbitAddedByByTrack = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (orbitRole !== 'host' || !orbitState) return map;
|
||||
if (orbitState.currentTrack) {
|
||||
map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy);
|
||||
}
|
||||
for (const q of orbitState.queue) map.set(q.trackId, q.addedBy);
|
||||
return map;
|
||||
}, [orbitRole, orbitState]);
|
||||
const orbitHostUsername = orbitState?.host ?? '';
|
||||
/** Attribution label for a queue row / current track while hosting. Null when
|
||||
* not in a hosted session. Bulk-adds (album / playlist enqueue) bypass
|
||||
* `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we
|
||||
* default those to "Added by you" rather than showing nothing. */
|
||||
const orbitAttributionLabel = (trackId: string): string | null => {
|
||||
if (orbitRole !== 'host' || !orbitState) return null;
|
||||
const addedBy = orbitAddedByByTrack.get(trackId);
|
||||
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
||||
return t('orbit.queueAddedByUser', { user: addedBy });
|
||||
};
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
@@ -251,7 +306,16 @@ export default function QueuePanel() {
|
||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
// When the user picks a track *from* the queue list, suppress the
|
||||
// upcoming auto-scroll so their click target stays in view instead of
|
||||
// the list rebasing onto the next track. Auto-advance (natural playback)
|
||||
// never sets this flag, so it keeps its original "show what's next" behavior.
|
||||
const suppressNextAutoScrollRef = useRef(false);
|
||||
|
||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
||||
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
||||
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
@@ -261,14 +325,27 @@ export default function QueuePanel() {
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
||||
const replayGainMode = useAuthStore(s => s.replayGainMode);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
const luckyRolling = useLuckyMixStore(s => s.isRolling);
|
||||
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
|
||||
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
|
||||
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
|
||||
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
|
||||
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
|
||||
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
|
||||
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
|
||||
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover) return;
|
||||
@@ -283,10 +360,84 @@ export default function QueuePanel() {
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
lufsTgtBtnRef.current?.contains(e.target as Node) ||
|
||||
lufsTgtMenuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLufsTgtOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
const updateLufsTgtPopStyle = () => {
|
||||
if (!lufsTgtBtnRef.current) return;
|
||||
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 160;
|
||||
const MAX_H = 220;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - WIDTH, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setLufsTgtPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
updateLufsTgtPopStyle();
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const onResize = () => updateLufsTgtPopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
// Tracks which queue index is being psy-dragged for opacity visual feedback
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
|
||||
return () => registerQueueListScrollTopReader(null);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const top = consumePendingQueueListScrollTop();
|
||||
if (top === undefined) return;
|
||||
const el = queueListRef.current;
|
||||
if (!el) return;
|
||||
suppressNextAutoScrollRef.current = true;
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}, [queue, queueIndex, currentTrack?.id]);
|
||||
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
@@ -357,13 +508,20 @@ export default function QueuePanel() {
|
||||
}, [enqueueAt]);
|
||||
|
||||
useEffect(function queueAutoScroll() {
|
||||
if (suppressNextAutoScrollRef.current) {
|
||||
suppressNextAutoScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!queueListRef.current || queueIndex < 0) return;
|
||||
if (activeTab !== 'queue') return;
|
||||
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
const nextSong = songs[queueIndex + 1];
|
||||
if (!nextSong) return;
|
||||
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
}, [currentTrack, activeTab]);
|
||||
requestAnimationFrame(() => {
|
||||
queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
});
|
||||
}, [currentTrack, activeTab]);
|
||||
|
||||
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
@@ -396,6 +554,18 @@ export default function QueuePanel() {
|
||||
setActivePlaylist(null);
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (queue.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
const srv = useAuthStore.getState().getBaseUrl();
|
||||
if (!srv) return;
|
||||
const ids = queue.map(t => t.id);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -426,6 +596,12 @@ export default function QueuePanel() {
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{orbitRole === 'host' && orbitState && (
|
||||
<>
|
||||
<OrbitQueueHead state={orbitState} />
|
||||
<HostApprovalQueue />
|
||||
</>
|
||||
)}
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
@@ -451,28 +627,171 @@ export default function QueuePanel() {
|
||||
})(),
|
||||
].filter(Boolean) as string[];
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const techLine = [...baseParts, ...rgParts].join(' · ');
|
||||
if (!techLine && !playbackSource) return null;
|
||||
const baseLine = baseParts.join(' · ');
|
||||
const rgLine = rgParts.join(' · ');
|
||||
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
|
||||
const liveGainLabel = (() => {
|
||||
if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
|
||||
return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
|
||||
}
|
||||
if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
|
||||
const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
|
||||
loudnessPreAnalysisAttenuationDb,
|
||||
authLoudnessTargetLufs,
|
||||
);
|
||||
const ph = loudnessGainPlaceholderUntilCacheDb(
|
||||
authLoudnessTargetLufs,
|
||||
preEff,
|
||||
);
|
||||
return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
|
||||
}
|
||||
return '—';
|
||||
})();
|
||||
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
|
||||
const targetLabel = `${tgtNum} LUFS`;
|
||||
if (!baseLine && !rgLine && !playbackSource) return null;
|
||||
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
||||
const showLufsLine = isLoudnessActive && expandReplayGain;
|
||||
return (
|
||||
<div className="queue-current-tech">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
<span className="queue-current-tech-main">{techLine}</span>
|
||||
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
|
||||
<div className="queue-current-tech-stack">
|
||||
<div className="queue-current-tech-row">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
|
||||
{!isLoudnessActive && rgLine && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
|
||||
aria-expanded={showRgLine}
|
||||
aria-label={t('queue.replayGain')}
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
RG
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
{isLoudnessActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
|
||||
aria-expanded={showLufsLine}
|
||||
aria-label="LUFS"
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
LUFS
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRgLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
|
||||
{' · '}{rgLine}
|
||||
</span>
|
||||
)}
|
||||
{showLufsLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">Loudness</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className="queue-current-tech-metric queue-current-tech-metric--lufs-reanalyze"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(false);
|
||||
void reanalyzeLoudnessForTrack(currentTrack.id);
|
||||
}}
|
||||
data-tooltip="Clear cached loudness and re-analyze this track"
|
||||
aria-label="Clear cached loudness and re-analyze this track"
|
||||
>
|
||||
{liveGainLabel}
|
||||
</button>
|
||||
{' · '}
|
||||
<span className="queue-current-tech-rg-label">TGT</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
ref={lufsTgtBtnRef}
|
||||
className="queue-current-tech-metric"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(v => !v);
|
||||
}}
|
||||
data-tooltip="Change target integrated loudness"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={lufsTgtOpen}
|
||||
>
|
||||
{targetLabel}
|
||||
</button>
|
||||
{lufsTgtOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={lufsTgtMenuRef}
|
||||
className="queue-lufs-tgt-menu"
|
||||
style={{
|
||||
...lufsTgtPopStyle,
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border, rgba(255,255,255,0.12))',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.35)',
|
||||
padding: 6,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="LUFS target"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{([-10, -12, -14, -16] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (v !== authLoudnessTargetLufs) {
|
||||
setLoudnessTargetLufs(v);
|
||||
}
|
||||
setLufsTgtOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: v === authLoudnessTargetLufs ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{v} LUFS
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -497,6 +816,10 @@ export default function QueuePanel() {
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{(() => {
|
||||
const label = orbitAttributionLabel(currentTrack.id);
|
||||
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
|
||||
})()}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,6 +843,14 @@ export default function QueuePanel() {
|
||||
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={t('queue.shareQueue')}
|
||||
aria-label={t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
@@ -530,7 +861,7 @@ export default function QueuePanel() {
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<Infinity size={13} />
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -582,19 +913,27 @@ export default function QueuePanel() {
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<ArrowUpToLine size={13} />
|
||||
<Infinity size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<div className="queue-list" ref={queueListRef}>
|
||||
|
||||
<OverlayScrollArea
|
||||
viewportRef={queueListRef}
|
||||
className="queue-list-wrap"
|
||||
viewportClassName="queue-list"
|
||||
measureDeps={[activeTab, queue.length]}
|
||||
railInset="panel"
|
||||
viewportScrollBehaviorAuto={isQueueDrag}
|
||||
>
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">
|
||||
{t('queue.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
<>
|
||||
{queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
@@ -625,7 +964,10 @@ export default function QueuePanel() {
|
||||
<div
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onClick={() => {
|
||||
suppressNextAutoScrollRef.current = true;
|
||||
playTrack(track, queue);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
|
||||
@@ -658,18 +1000,51 @@ export default function QueuePanel() {
|
||||
<span className="truncate">{track.title}</span>
|
||||
</div>
|
||||
<div className="queue-item-artist truncate">{track.artist}</div>
|
||||
{(() => {
|
||||
const label = orbitAttributionLabel(track.id);
|
||||
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="queue-item-duration">
|
||||
{formatTime(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
{luckyRolling && isPlaying && (
|
||||
<button
|
||||
type="button"
|
||||
className="queue-lucky-loading"
|
||||
onClick={() => useLuckyMixStore.getState().cancel()}
|
||||
data-tooltip={t('luckyMix.cancelTooltip')}
|
||||
aria-label={t('luckyMix.cancelTooltip')}
|
||||
>
|
||||
<div className="queue-lucky-loading__dice">
|
||||
<div className="queue-lucky-cube queue-lucky-cube--a">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tr" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--bl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
||||
</div>
|
||||
<div className="queue-lucky-cube queue-lucky-cube--b">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
||||
</div>
|
||||
<div className="queue-lucky-cube queue-lucky-cube--c">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>) : (
|
||||
</OverlayScrollArea>
|
||||
</>) : activeTab === 'lyrics' ? (
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
) : (
|
||||
<NowPlayingInfo />
|
||||
)}
|
||||
|
||||
<div className="queue-tab-bar">
|
||||
@@ -689,6 +1064,14 @@ export default function QueuePanel() {
|
||||
<MicVocal size={14} />
|
||||
{t('player.lyrics')}
|
||||
</button>
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'info' ? ' active' : ''}`}
|
||||
onClick={() => setTab('info')}
|
||||
aria-label={t('nowPlayingInfo.tab')}
|
||||
>
|
||||
<Info size={14} />
|
||||
{t('nowPlayingInfo.tab')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveModalOpen && (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useId } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface SettingsSubSectionProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
// Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden
|
||||
// gestoppt, damit sie das Accordion nicht togglen.
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Wird innerhalb eines Settings-Tabs als Accordion-Gruppe genutzt. Natives
|
||||
// <details> liefert Keyboard + ARIA gratis; der CSS-Stil setzt den Chevron
|
||||
// im Summary mittels [open]-Selektor.
|
||||
export default function SettingsSubSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
description,
|
||||
searchText,
|
||||
action,
|
||||
children,
|
||||
}: SettingsSubSectionProps) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<details
|
||||
className="settings-sub-section"
|
||||
data-settings-search={searchText ?? title}
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary
|
||||
className="settings-sub-section-summary"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
{icon && <span className="settings-sub-section-icon">{icon}</span>}
|
||||
<span id={headingId} className="settings-sub-section-title">{title}</span>
|
||||
{action && (
|
||||
<span
|
||||
className="settings-sub-section-action"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={16} className="settings-sub-section-chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
{description && (
|
||||
<p className="settings-sub-section-desc">{description}</p>
|
||||
)}
|
||||
<div className="settings-sub-section-content">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
+427
-41
@@ -5,19 +5,50 @@ import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
import WhatsNewBanner from './WhatsNewBanner';
|
||||
import { getPlaylists } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import {
|
||||
applySidebarDropReorder,
|
||||
getLibraryItemsForReorder,
|
||||
getSystemItemsForReorder,
|
||||
isSidebarNavItemUserHideable,
|
||||
type SidebarNavDropTarget,
|
||||
} from '../utils/sidebarNavReorder';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
|
||||
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
||||
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
||||
const SMART_PREFIX = 'psy-smart-';
|
||||
|
||||
function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
||||
const aside = document.querySelector('aside.sidebar');
|
||||
if (!aside) return false;
|
||||
const r = aside.getBoundingClientRect();
|
||||
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
|
||||
}
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
@@ -47,7 +78,12 @@ export default function Sidebar({
|
||||
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
|
||||
// in hub mode it lives inside the Build-a-Mix landing page instead.
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
const playlistsRaw = usePlaylistStore(s => s.playlists);
|
||||
@@ -66,6 +102,244 @@ export default function Sidebar({
|
||||
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
|
||||
const libraryTriggerPlain = filterId === 'all';
|
||||
|
||||
const libraryItemsForReorder = useMemo(
|
||||
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
|
||||
[sidebarItems, randomNavMode],
|
||||
);
|
||||
const systemItemsForReorder = useMemo(
|
||||
() => getSystemItemsForReorder(sidebarItems),
|
||||
[sidebarItems],
|
||||
);
|
||||
const visibleLibraryConfigs = useMemo(
|
||||
() =>
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
[systemItemsForReorder],
|
||||
);
|
||||
|
||||
const [navDnd, setNavDnd] = useState<{
|
||||
section: 'library' | 'system';
|
||||
fromIdx: number;
|
||||
} | null>(null);
|
||||
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||
navDropTargetRef.current = navDropTarget;
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
sidebarItemsRef.current = sidebarItems;
|
||||
const randomNavModeRef = useRef(randomNavMode);
|
||||
randomNavModeRef.current = randomNavMode;
|
||||
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
||||
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
||||
const suppressNavClickRef = useRef(false);
|
||||
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
|
||||
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navDnd) return;
|
||||
|
||||
const updateDropFromPoint = (clientX: number, clientY: number) => {
|
||||
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
|
||||
navDropTargetRef.current = null;
|
||||
setNavDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
|
||||
let target: SidebarNavDropTarget | null = null;
|
||||
for (const row of rows) {
|
||||
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
|
||||
if (section !== navDnd.section) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.sidebarIdx);
|
||||
if (Number.isNaN(idx)) continue;
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
target = { idx, before: true, section };
|
||||
break;
|
||||
}
|
||||
target = { idx, before: false, section };
|
||||
}
|
||||
navDropTargetRef.current = target;
|
||||
setNavDropTarget(target);
|
||||
};
|
||||
|
||||
const endDrag = (apply: boolean) => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
|
||||
const currentDnd = navDnd;
|
||||
const drop = navDropTargetRef.current;
|
||||
setNavDnd(null);
|
||||
setNavDropTarget(null);
|
||||
navDropTargetRef.current = null;
|
||||
|
||||
if (!apply || !currentDnd) return;
|
||||
|
||||
const { x, y } = lastPointerDuringNavDndRef.current;
|
||||
if (isPointerOutsideAsideSidebar(x, y)) {
|
||||
const sectionItems =
|
||||
currentDnd.section === 'library'
|
||||
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||
const id = sectionItems[currentDnd.fromIdx]?.id;
|
||||
if (id && isSidebarNavItemUserHideable(id)) {
|
||||
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
|
||||
i.id === id ? { ...i, visible: false } : i,
|
||||
);
|
||||
setSidebarItems(nextItems);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applySidebarDropReorder(
|
||||
sidebarItemsRef.current,
|
||||
currentDnd.section,
|
||||
currentDnd.fromIdx,
|
||||
drop,
|
||||
randomNavModeRef.current,
|
||||
);
|
||||
if (next) {
|
||||
setSidebarItems(next);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
|
||||
const sectionItems =
|
||||
navDnd.section === 'library'
|
||||
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||
const draggedId = sectionItems[navDnd.fromIdx]?.id;
|
||||
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
|
||||
if (outside && canTrash) {
|
||||
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
setNavDndTrashHint(null);
|
||||
}
|
||||
|
||||
updateDropFromPoint(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const onUp = (e: PointerEvent) => {
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
endDrag(true);
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
endDrag(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
window.addEventListener('pointercancel', onUp, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
};
|
||||
}, [navDnd, setSidebarItems]);
|
||||
|
||||
const handleNavRowPointerDown = useCallback(
|
||||
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
|
||||
if (isCollapsed || navDnd) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
const pid = e.pointerId;
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
|
||||
let cleaned = false;
|
||||
const cleanupEarly = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
document.removeEventListener('pointermove', onEarlyMove);
|
||||
document.removeEventListener('pointerup', onEarlyUp, true);
|
||||
document.removeEventListener('pointercancel', onEarlyUp, true);
|
||||
};
|
||||
|
||||
const onEarlyMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
}
|
||||
};
|
||||
|
||||
const onEarlyUp = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
|
||||
setNavDnd({ section, fromIdx: sectionIdx });
|
||||
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
|
||||
setNavDropTarget({ idx: sectionIdx, before: true, section });
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(pid);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
|
||||
|
||||
longPressTimersRef.current.set(pid, timer);
|
||||
document.addEventListener('pointermove', onEarlyMove);
|
||||
document.addEventListener('pointerup', onEarlyUp, true);
|
||||
document.addEventListener('pointercancel', onEarlyUp, true);
|
||||
},
|
||||
[isCollapsed, navDnd],
|
||||
);
|
||||
|
||||
const navDndRowClass = useCallback(
|
||||
(section: 'library' | 'system', sectionIdx: number) => {
|
||||
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
|
||||
let drop = '';
|
||||
if (
|
||||
navDnd &&
|
||||
navDropTarget?.section === section &&
|
||||
navDropTarget.idx === sectionIdx &&
|
||||
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
|
||||
) {
|
||||
drop = navDropTarget.before
|
||||
? 'sidebar-nav-dnd-row--drop-before'
|
||||
: 'sidebar-nav-dnd-row--drop-after';
|
||||
}
|
||||
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
|
||||
},
|
||||
[navDnd, navDropTarget],
|
||||
);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const el = libraryTriggerRef.current;
|
||||
if (!el) return;
|
||||
@@ -120,22 +394,13 @@ export default function Sidebar({
|
||||
fetchPlaylists();
|
||||
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => {
|
||||
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
|
||||
// Hide mode-inactive mix entries so the active mode controls what's shown
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
const visibleSystem = sidebarItems
|
||||
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
useEffect(() => () => {
|
||||
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
|
||||
longPressTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
{isCollapsed
|
||||
@@ -153,7 +418,35 @@ export default function Sidebar({
|
||||
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
||||
</button>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Main navigation">
|
||||
<nav
|
||||
className="sidebar-nav"
|
||||
aria-label="Main navigation"
|
||||
onClickCapture={e => {
|
||||
if (suppressNavClickRef.current) {
|
||||
suppressNavClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OverlayScrollArea
|
||||
className="sidebar-nav-scroll"
|
||||
viewportClassName="sidebar-nav-viewport"
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
isCollapsed,
|
||||
playlistsExpanded,
|
||||
playlists.length,
|
||||
isLoggedIn,
|
||||
randomNavMode,
|
||||
filterId,
|
||||
hasOfflineContent,
|
||||
activeJobs.length,
|
||||
isSyncing,
|
||||
syncJobTotal,
|
||||
sidebarItems.length,
|
||||
]}
|
||||
>
|
||||
{!isCollapsed && (showLibraryPicker ? (
|
||||
<>
|
||||
<button
|
||||
@@ -228,10 +521,29 @@ export default function Sidebar({
|
||||
) : (
|
||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||
))}
|
||||
{visibleLibrary.map(item => (
|
||||
item.to === '/playlists' ? (
|
||||
// Playlists item with expand button
|
||||
<div key={item.to} className="sidebar-playlists-wrapper">
|
||||
{visibleLibraryConfigs.map(cfg => {
|
||||
const item = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!item) return null;
|
||||
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
|
||||
const dndProps = dndRow
|
||||
? {
|
||||
'data-sidebar-nav-dnd-row': '',
|
||||
'data-sidebar-section': 'library' as const,
|
||||
'data-sidebar-idx': String(sectionIdx),
|
||||
onPointerDown: (e: React.PointerEvent) =>
|
||||
handleNavRowPointerDown(e, 'library', sectionIdx),
|
||||
}
|
||||
: {};
|
||||
|
||||
return item.to === '/playlists' ? (
|
||||
<div
|
||||
key={item.to}
|
||||
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<div className="sidebar-playlists-header-row">
|
||||
<NavLink
|
||||
to={item.to}
|
||||
@@ -268,18 +580,18 @@ export default function Sidebar({
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? pl.name : undefined}
|
||||
data-tooltip={isCollapsed ? displayPlaylistName(pl.name) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<PlayCircle size={12} />
|
||||
<span>{pl.name}</span>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : isCollapsed ? (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -291,8 +603,32 @@ export default function Sidebar({
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
))}
|
||||
) : (
|
||||
<div
|
||||
key={item.to}
|
||||
className={rowClass}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
||||
<div className="sidebar-bottom-spacer" />
|
||||
|
||||
{/* What's New banner — only visible while the current release hasn't been seen. */}
|
||||
<WhatsNewBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
<NavLink
|
||||
@@ -300,7 +636,6 @@ export default function Sidebar({
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ marginTop: 'auto' }}
|
||||
>
|
||||
<span className="nav-np-icon-wrap">
|
||||
<AudioLines size={isCollapsed ? 22 : 18} />
|
||||
@@ -321,19 +656,53 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystemConfigs.map(cfg => {
|
||||
const item = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!item) return null;
|
||||
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
|
||||
const dndProps = dndRow
|
||||
? {
|
||||
'data-sidebar-nav-dnd-row': '',
|
||||
'data-sidebar-section': 'system' as const,
|
||||
'data-sidebar-idx': String(sectionIdx),
|
||||
onPointerDown: (e: React.PointerEvent) =>
|
||||
handleNavRowPointerDown(e, 'system', sectionIdx),
|
||||
}
|
||||
: {};
|
||||
|
||||
return isCollapsed ? (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
) : (
|
||||
<div
|
||||
key={item.to}
|
||||
className={rowClass}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
@@ -378,7 +747,24 @@ export default function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</nav>
|
||||
</aside>
|
||||
{navDndTrashHint != null &&
|
||||
createPortal(
|
||||
<div
|
||||
className="sidebar-nav-dnd-trash-hint"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: navDndTrashHint.x + 14,
|
||||
top: navDndTrashHint.y + 14,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<Trash2 size={22} strokeWidth={2.25} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { enqueueAndPlay } from '../utils/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
}
|
||||
|
||||
function SongCard({ song }: SongCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : '';
|
||||
const psyDrag = useDragDrop();
|
||||
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
if (orbitActive) { addTrackToOrbit(song.id); return; }
|
||||
enqueueAndPlay(song);
|
||||
};
|
||||
|
||||
const handleEnqueue = () => {
|
||||
if (orbitActive) { addTrackToOrbit(song.id); return; }
|
||||
enqueue([songToTrack(song)]);
|
||||
};
|
||||
|
||||
const handleClick = handlePlay;
|
||||
|
||||
const handleArtistClick = (e: React.MouseEvent) => {
|
||||
if (!song.artistId) return;
|
||||
e.stopPropagation();
|
||||
navigate(`/artist/${song.artistId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="song-card card"
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${song.title} – ${song.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, song, 'song');
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
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', id: song.id, name: song.title }), label: song.title, 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="song-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
|
||||
alt={`${song.album} Cover`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="song-card-cover-placeholder">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="song-card-play-overlay">
|
||||
<button
|
||||
className="song-card-action-btn"
|
||||
onClick={e => { e.stopPropagation(); handlePlay(); }}
|
||||
aria-label={t('tracks.playSong')}
|
||||
data-tooltip={t('tracks.playSong')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="song-card-action-btn"
|
||||
onClick={e => { e.stopPropagation(); handleEnqueue(); }}
|
||||
aria-label={t('tracks.enqueueSong')}
|
||||
data-tooltip={t('tracks.enqueueSong')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="song-card-info">
|
||||
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
|
||||
<p
|
||||
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={handleArtistClick}
|
||||
title={song.artist}
|
||||
>{song.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SongCard);
|
||||
@@ -5,6 +5,8 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyTextToClipboard } from '../utils/serverMagicString';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -28,6 +30,27 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Title / Artist / Album: double-click the value cell to copy plain text. */
|
||||
function CopyableFieldRow({ label, text }: { label: string; text: string | null | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
if (!text || text === '—') return null;
|
||||
const onDoubleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ok = await copyTextToClipboard(text);
|
||||
if (ok) showToast(t('orbit.tooltipCopied'), 2000, 'info');
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 3500, 'error');
|
||||
};
|
||||
return (
|
||||
<tr>
|
||||
<td className="song-info-label">{label}</td>
|
||||
<td className="song-info-value song-info-value--no-select" onDoubleClick={onDoubleClick}>
|
||||
{text}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
|
||||
}
|
||||
@@ -96,9 +119,9 @@ export default function SongInfoModal() {
|
||||
{!loading && song && (
|
||||
<table className="song-info-table">
|
||||
<tbody>
|
||||
<Row label={t('songInfo.songTitle')} value={song.title} />
|
||||
<Row label={t('songInfo.artist')} value={song.artist} />
|
||||
<Row label={t('songInfo.album')} value={song.album} />
|
||||
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
|
||||
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
|
||||
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
|
||||
{song.albumArtist && song.albumArtist !== song.artist && (
|
||||
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import SongCard from './SongCard';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
songs: SubsonicSong[];
|
||||
/** Called when user clicks the reroll button (visible only if provided). */
|
||||
onReroll?: () => void | Promise<void>;
|
||||
/** Loading state — disables reroll, optional shimmer */
|
||||
loading?: boolean;
|
||||
/** Empty-state copy when songs is empty AND not loading. */
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [songs]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Hide rail entirely if empty and no empty-state copy
|
||||
if (songs.length === 0 && !loading && !emptyText) return null;
|
||||
|
||||
return (
|
||||
<section className="song-row-section">
|
||||
<div className="song-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="song-row-nav">
|
||||
{onReroll && (
|
||||
<button
|
||||
className="nav-btn song-row-reroll"
|
||||
onClick={() => onReroll()}
|
||||
disabled={loading}
|
||||
aria-label="Reroll"
|
||||
data-tooltip="Reroll"
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
|
||||
</button>
|
||||
)}
|
||||
<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}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="song-grid-wrapper">
|
||||
{songs.length === 0 && emptyText ? (
|
||||
<p className="song-row-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{songs.map(s => (
|
||||
<SongCard key={s.id} song={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { enqueueAndPlay } from '../utils/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (!s || !isFinite(s)) return '–';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
song: SubsonicSong;
|
||||
}
|
||||
|
||||
function SongRow({ song }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
|
||||
const psyDrag = useDragDrop();
|
||||
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
// In an orbit session both buttons collapse into the orbit-suggest / host-enqueue
|
||||
// path so we don't ship a queue replacement to every guest.
|
||||
const handlePlay = () => {
|
||||
if (orbitActive) { addTrackToOrbit(song.id); return; }
|
||||
enqueueAndPlay(song);
|
||||
};
|
||||
|
||||
const handleEnqueue = () => {
|
||||
if (orbitActive) { addTrackToOrbit(song.id); return; }
|
||||
enqueue([songToTrack(song)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`song-list-row${isCurrent ? ' is-current' : ''}`}
|
||||
onDoubleClick={handlePlay}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, song, 'song');
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const track = songToTrack(song);
|
||||
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 }), 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);
|
||||
}}
|
||||
>
|
||||
<div className="song-list-row-cell song-list-row-actions">
|
||||
<button
|
||||
className="song-list-row-btn song-list-row-btn--play"
|
||||
onClick={(e) => { e.stopPropagation(); handlePlay(); }}
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="song-list-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
|
||||
aria-label="Enqueue"
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="song-list-row-cell song-list-row-title truncate" title={song.title}>{song.title}</div>
|
||||
<div className="song-list-row-cell truncate">
|
||||
<span
|
||||
className={song.artistId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
title={song.artist}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="song-list-row-cell truncate">
|
||||
{song.albumId ? (
|
||||
<span
|
||||
className="track-artist-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
|
||||
title={song.album}
|
||||
>{song.album}</span>
|
||||
) : <span title={song.album}>{song.album}</span>}
|
||||
</div>
|
||||
<div className="song-list-row-cell song-list-row-genre truncate" title={song.genre ?? ''}>
|
||||
{song.genre ?? '—'}
|
||||
</div>
|
||||
<div className="song-list-row-cell song-list-row-duration">{fmtDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
|
||||
export function SongListHeader() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="song-list-row song-list-row--header" role="row">
|
||||
<div className="song-list-row-cell song-list-row-actions" />
|
||||
<div className="song-list-row-cell">{t('albumDetail.trackTitle')}</div>
|
||||
<div className="song-list-row-cell">{t('albumDetail.trackArtist')}</div>
|
||||
<div className="song-list-row-cell">{t('albumDetail.trackAlbum')}</div>
|
||||
<div className="song-list-row-cell">{t('randomMix.trackGenre')}</div>
|
||||
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SongRow);
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownUp, Check } from 'lucide-react';
|
||||
|
||||
export interface SortOption<V extends string> {
|
||||
value: V;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<V extends string> {
|
||||
value: V;
|
||||
options: SortOption<V>[];
|
||||
onChange: (value: V) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const current = options.find(o => o.value === value);
|
||||
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = Math.max(rect.width, 220);
|
||||
const MAX_H = 300;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => updatePopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!popRef.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="btn btn-surface"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
{current?.label ?? value}
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="genre-filter-popover"
|
||||
style={popStyle}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="genre-filter-popover__list">
|
||||
{options.map(opt => {
|
||||
const isSel = opt.value === value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
>
|
||||
<span className="genre-filter-popover__check">
|
||||
{isSel && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { X, Minus, Square } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export default function TitleBar() {
|
||||
@@ -10,8 +9,6 @@ export default function TitleBar() {
|
||||
|
||||
return (
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
|
||||
|
||||
<div className="titlebar-track" data-tauri-drag-region>
|
||||
{currentTrack && (
|
||||
<>
|
||||
@@ -29,25 +26,22 @@ export default function TitleBar() {
|
||||
onClick={() => win.minimize()}
|
||||
data-tooltip="Minimize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
aria-label="Minimize"
|
||||
/>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-maximize"
|
||||
onClick={() => win.toggleMaximize()}
|
||||
data-tooltip="Maximize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Square size={9} />
|
||||
</button>
|
||||
aria-label="Maximize"
|
||||
/>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-close"
|
||||
onClick={() => win.close()}
|
||||
data-tooltip="Close"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user