mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 08:15:45 +00:00
Compare commits
220 Commits
v1.43.0
...
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 |
@@ -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
-312
@@ -1,317 +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: re-sign updater bundle + upload .sig (macOS only)
|
||||
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
|
||||
# done, which invalidates the .sig tauri CLI created (different hash).
|
||||
# We can't stop the repack (it's tied to includeUpdaterJson), so we
|
||||
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
|
||||
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
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
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 "app-v${VERSION}" \
|
||||
"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
|
||||
- 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
|
||||
pull-requests: 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: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
# The cachix-action daemon writes a post-build-hook into the user
|
||||
# nix.conf, but the Determinate Nix daemon that runs the builds reads
|
||||
# the system nix.conf — so the hook never fires and only a couple of
|
||||
# early prep paths get uploaded. Force an explicit closure push here;
|
||||
# cachix dedupes against anything already in the cache.
|
||||
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-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 main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated after the v${VERSION} release: refreshes \`flake.lock\` and \`nix/upstream-sources.json\` so the Cachix substituter resolves the latest pin."
|
||||
gh pr merge "$BRANCH" --squash --auto --delete-branch
|
||||
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}).`);
|
||||
+431
@@ -13,6 +13,437 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
> **📦 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.44.0] - 2026-04-29
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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,165 +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://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
|
||||
<a href="https://discord.gg/AMnDRErm4u"><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, NixOS flake).
|
||||
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
|
||||
> [!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
|
||||
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
|
||||
## Windows
|
||||
|
||||
**Arch Linux (AUR):**
|
||||
Download the latest installer from Releases.
|
||||
|
||||
| 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. |
|
||||
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
## macOS
|
||||
|
||||
**❄️ NixOS (flakes):**
|
||||
- `nix run github:Psychotoxical/psysonic` — one-shot launch
|
||||
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
|
||||
Download the signed DMG from Releases.
|
||||
|
||||
### 🍎 macOS
|
||||
---
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
|
||||
|
||||
> [!NOTE]
|
||||
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 🚀 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-5YAQ2PqxJtD7+adecF++swOHVKNstEkyRQeiBrP2lvA="
|
||||
"npmDepsHash": "sha256-PYqvngGz/yreDiQxAEIhD5nuEPezAELj/zaEpJ7c1f4="
|
||||
}
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.42.1",
|
||||
"version": "1.44.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.42.1",
|
||||
"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.43.0",
|
||||
"version": "1.44.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.42.1
|
||||
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
+78
-3
@@ -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"
|
||||
@@ -3653,10 +3706,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.43.0"
|
||||
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",
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.43.0"
|
||||
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"
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+1229
-179
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(())
|
||||
|
||||
+1328
-83
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);
|
||||
}
|
||||
}};
|
||||
}
|
||||
@@ -85,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.43.0",
|
||||
"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": {
|
||||
|
||||
+209
-79
@@ -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,40 +24,56 @@ 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';
|
||||
import WhatsNew from './pages/WhatsNew';
|
||||
|
||||
// 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 AppUpdater from './components/AppUpdater';
|
||||
import TitleBar from './components/TitleBar';
|
||||
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';
|
||||
@@ -75,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';
|
||||
@@ -84,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;
|
||||
@@ -94,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(() => {});
|
||||
@@ -147,6 +207,7 @@ 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);
|
||||
@@ -176,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;
|
||||
@@ -200,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
|
||||
@@ -290,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();
|
||||
@@ -324,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);
|
||||
|
||||
@@ -334,16 +410,15 @@ 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 window is minimized / hidden.
|
||||
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
|
||||
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
|
||||
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
|
||||
// components.css pauses all running animations while this flag is set.
|
||||
// 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';
|
||||
@@ -383,6 +458,7 @@ function AppShell() {
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<OrbitStartTrigger />
|
||||
<button
|
||||
className="queue-toggle-btn"
|
||||
onClick={toggleQueue}
|
||||
@@ -392,39 +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="/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>
|
||||
<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>
|
||||
@@ -433,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' }}
|
||||
@@ -447,6 +538,9 @@ function AppShell() {
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
</div>
|
||||
@@ -770,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) {
|
||||
@@ -882,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();
|
||||
@@ -1014,6 +1140,7 @@ export default function App() {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<GlobalConfirmModal />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
@@ -1084,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,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);
|
||||
}
|
||||
+85
-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() {
|
||||
@@ -80,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 {
|
||||
@@ -120,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 {
|
||||
@@ -287,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,
|
||||
});
|
||||
@@ -299,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 };
|
||||
}
|
||||
}
|
||||
@@ -489,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 });
|
||||
@@ -526,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[],
|
||||
@@ -536,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);
|
||||
}
|
||||
@@ -549,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 */
|
||||
@@ -572,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);
|
||||
}
|
||||
@@ -585,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 */
|
||||
@@ -892,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 });
|
||||
}
|
||||
@@ -944,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()}`;
|
||||
}
|
||||
@@ -964,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()}`;
|
||||
}
|
||||
@@ -978,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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,10 +7,15 @@ interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: 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;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
@@ -23,15 +28,17 @@ export default function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const dismiss = onCancel ?? onConfirm;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if (e.key === 'Escape') dismiss();
|
||||
else if (e.key === 'Enter') onConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onCancel, onConfirm]);
|
||||
}, [open, dismiss, onConfirm]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -42,7 +49,7 @@ export default function ConfirmModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onCancel}
|
||||
onClick={dismiss}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
@@ -52,7 +59,7 @@ export default function ConfirmModal({
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: '380px' }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
|
||||
<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>
|
||||
@@ -60,10 +67,12 @@ export default function ConfirmModal({
|
||||
{message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
|
||||
{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>
|
||||
|
||||
@@ -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,12 +1,13 @@
|
||||
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';
|
||||
@@ -14,6 +15,10 @@ import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
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';
|
||||
@@ -428,6 +433,28 @@ 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(() => {
|
||||
const s = usePlayerStore.getState();
|
||||
@@ -438,6 +465,7 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
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}%`;
|
||||
@@ -447,8 +475,10 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||
[seek]
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
previewSeek(parseFloat(e.target.value));
|
||||
},
|
||||
[previewSeek]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -466,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>
|
||||
@@ -549,14 +587,40 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, tr
|
||||
});
|
||||
|
||||
// ─── 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} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -676,13 +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);
|
||||
@@ -789,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+111
-56
@@ -1,15 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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, ArrowUpToLine } from 'lucide-react';
|
||||
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 };
|
||||
@@ -107,12 +111,16 @@ export default function MiniPlayer() {
|
||||
});
|
||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
||||
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
|
||||
const [volumeOpen, setVolumeOpen] = useState(false);
|
||||
const ticker = useRef<number | null>(null);
|
||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||
const volumeWrapRef = 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
|
||||
@@ -137,25 +145,6 @@ export default function MiniPlayer() {
|
||||
// ── Context menu state ──
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
|
||||
|
||||
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
|
||||
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
|
||||
// items so the queue keeps its full width.
|
||||
const recomputeScroll = useCallback(() => {
|
||||
const el = queueScrollRef.current;
|
||||
if (!el) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (scrollHeight <= clientHeight + 1) {
|
||||
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
|
||||
return;
|
||||
}
|
||||
const ratio = clientHeight / scrollHeight;
|
||||
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
|
||||
const range = clientHeight - thumbH;
|
||||
const scrollRange = scrollHeight - clientHeight;
|
||||
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
|
||||
setScrollMeta({ thumbH, thumbT, visible: true });
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
@@ -169,6 +158,21 @@ export default function MiniPlayer() {
|
||||
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
|
||||
@@ -217,6 +221,16 @@ export default function MiniPlayer() {
|
||||
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(() => {});
|
||||
@@ -238,6 +252,7 @@ export default function MiniPlayer() {
|
||||
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);
|
||||
});
|
||||
@@ -262,13 +277,58 @@ export default function MiniPlayer() {
|
||||
handleVolumeChange(volume === 0 ? 1 : 0);
|
||||
};
|
||||
|
||||
// Close the volume popover on outside click / Escape.
|
||||
// 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) => {
|
||||
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) {
|
||||
setVolumeOpen(false);
|
||||
}
|
||||
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);
|
||||
@@ -360,17 +420,11 @@ export default function MiniPlayer() {
|
||||
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]);
|
||||
|
||||
// Recompute overlay-thumb on open, queue mutations, and window resize.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
recomputeScroll();
|
||||
const onResize = () => recomputeScroll();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [queueOpen, state.queue.length, recomputeScroll]);
|
||||
|
||||
const { track, isPlaying } = state;
|
||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
@@ -457,8 +511,9 @@ export default function MiniPlayer() {
|
||||
</div>
|
||||
|
||||
<div className="mini-player__toolbar" data-tauri-drag-region="false">
|
||||
<div className="mini-player__volume-wrap" ref={volumeWrapRef}>
|
||||
<div className="mini-player__volume-wrap">
|
||||
<button
|
||||
ref={volumeBtnRef}
|
||||
type="button"
|
||||
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => setVolumeOpen(v => !v)}
|
||||
@@ -469,8 +524,13 @@ export default function MiniPlayer() {
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
|
||||
</button>
|
||||
{volumeOpen && (
|
||||
<div className="mini-player__volume-popover" data-tauri-drag-region="false">
|
||||
{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"
|
||||
@@ -505,7 +565,8 @@ export default function MiniPlayer() {
|
||||
style={{ height: `${Math.round(volume * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,7 +592,7 @@ export default function MiniPlayer() {
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<InfinityIcon size={13} />
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -553,7 +614,7 @@ export default function MiniPlayer() {
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<ArrowUpToLine size={13} />
|
||||
<InfinityIcon size={13} />
|
||||
</button>
|
||||
|
||||
<span className="mini-player__toolbar-sep" aria-hidden />
|
||||
@@ -571,8 +632,13 @@ export default function MiniPlayer() {
|
||||
</div>
|
||||
|
||||
{queueOpen && (
|
||||
<div
|
||||
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
|
||||
<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]');
|
||||
@@ -591,7 +657,6 @@ export default function MiniPlayer() {
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
||||
) : (
|
||||
@@ -651,17 +716,7 @@ export default function MiniPlayer() {
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scrollMeta.visible && (
|
||||
<div
|
||||
className="mini-queue__thumb"
|
||||
style={{
|
||||
height: `${scrollMeta.thumbH}px`,
|
||||
transform: `translateY(${scrollMeta.thumbT}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollArea>
|
||||
)}
|
||||
|
||||
<div className="mini-player__bottom" data-tauri-drag-region="false">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
|
||||
PictureInPicture2, ArrowLeftRight,
|
||||
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -21,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';
|
||||
@@ -136,6 +140,11 @@ export default function PlayerBar() {
|
||||
};
|
||||
}, [floatingPlayerBar]);
|
||||
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||
@@ -185,6 +194,7 @@ export default function PlayerBar() {
|
||||
};
|
||||
|
||||
const playerBarContent = (
|
||||
<>
|
||||
<footer
|
||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
|
||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||
@@ -292,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>
|
||||
@@ -448,6 +469,8 @@ export default function PlayerBar() {
|
||||
)}
|
||||
|
||||
</footer>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (floatingPlayerBar) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+371
-16
@@ -1,18 +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, ChevronDown } 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';
|
||||
@@ -226,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);
|
||||
@@ -252,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);
|
||||
@@ -262,16 +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;
|
||||
@@ -286,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();
|
||||
@@ -360,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');
|
||||
@@ -399,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
|
||||
@@ -429,6 +596,12 @@ export default function QueuePanel() {
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{orbitRole === 'host' && orbitState && (
|
||||
<>
|
||||
<OrbitQueueHead state={orbitState} />
|
||||
<HostApprovalQueue />
|
||||
</>
|
||||
)}
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
@@ -456,8 +629,29 @@ export default function QueuePanel() {
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
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 = expandReplayGain && !!rgLine;
|
||||
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
||||
const showLufsLine = isLoudnessActive && expandReplayGain;
|
||||
return (
|
||||
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
|
||||
<div className="queue-current-tech-stack">
|
||||
@@ -480,7 +674,7 @@ export default function QueuePanel() {
|
||||
</span>
|
||||
)}
|
||||
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
|
||||
{rgLine && (
|
||||
{!isLoudnessActive && rgLine && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
@@ -493,6 +687,19 @@ export default function QueuePanel() {
|
||||
<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">
|
||||
@@ -500,6 +707,90 @@ export default function QueuePanel() {
|
||||
{' · '}{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>
|
||||
);
|
||||
@@ -525,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>
|
||||
@@ -548,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>
|
||||
@@ -558,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
|
||||
@@ -610,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);
|
||||
@@ -653,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);
|
||||
@@ -686,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">
|
||||
@@ -717,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>
|
||||
);
|
||||
}
|
||||
+420
-40
@@ -5,13 +5,13 @@ 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';
|
||||
@@ -19,6 +19,36 @@ 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({
|
||||
@@ -48,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);
|
||||
@@ -67,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;
|
||||
@@ -121,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
|
||||
@@ -154,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
|
||||
@@ -229,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}
|
||||
@@ -269,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}
|
||||
@@ -292,8 +603,26 @@ 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" />
|
||||
@@ -327,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' : ''}`}
|
||||
@@ -384,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);
|
||||
@@ -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() {
|
||||
@@ -27,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SubsonicSong, searchSongsPaged } from '../api/subsonic';
|
||||
import { ndListSongs } from '../api/navidromeBrowse';
|
||||
import SongRow, { SongListHeader } from './SongRow';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const ROW_HEIGHT = 52;
|
||||
const PREFETCH_PX = 600;
|
||||
|
||||
/**
|
||||
* Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent).
|
||||
* Non-empty → Subsonic search3 (search isn't a browse).
|
||||
* Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3.
|
||||
*/
|
||||
async function fetchSongPage(query: string, offset: number): Promise<SubsonicSong[]> {
|
||||
if (query !== '') {
|
||||
return searchSongsPaged(query, PAGE_SIZE, offset);
|
||||
}
|
||||
try {
|
||||
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
|
||||
} catch {
|
||||
return searchSongsPaged('', PAGE_SIZE, offset);
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
emptyBrowseText?: string;
|
||||
}
|
||||
|
||||
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [browseUnsupported, setBrowseUnsupported] = useState(false);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
// Debounce query
|
||||
useEffect(() => {
|
||||
const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(h);
|
||||
}, [query]);
|
||||
|
||||
// Reset + first-page fetch on query change. One effect, no dep cascade,
|
||||
// and a `cancelled` flag so a fast typist doesn't see results from stale queries.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSongs([]);
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setBrowseUnsupported(false);
|
||||
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
|
||||
|
||||
const seq = ++requestSeqRef.current;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const page = await fetchSongPage(debouncedQuery, 0);
|
||||
if (cancelled || seq !== requestSeqRef.current) return;
|
||||
if (page.length === 0) {
|
||||
setHasMore(false);
|
||||
if (debouncedQuery === '') setBrowseUnsupported(true);
|
||||
} else {
|
||||
setSongs(page);
|
||||
setOffset(page.length);
|
||||
if (page.length < PAGE_SIZE) setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setHasMore(false);
|
||||
} finally {
|
||||
if (!cancelled && seq === requestSeqRef.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || !hasMore) return;
|
||||
setLoading(true);
|
||||
const seq = ++requestSeqRef.current;
|
||||
try {
|
||||
const page = await fetchSongPage(debouncedQuery, offset);
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
if (page.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setSongs(prev => {
|
||||
const seen = new Set(prev.map(s => s.id));
|
||||
const merged = [...prev];
|
||||
for (const s of page) if (!seen.has(s.id)) merged.push(s);
|
||||
return merged;
|
||||
});
|
||||
setOffset(o => o + page.length);
|
||||
if (page.length < PAGE_SIZE) setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [loading, hasMore, debouncedQuery, offset]);
|
||||
|
||||
// Scroll-based prefetch — uses ref so a stale loadMore can't loop
|
||||
const loadMoreRef = useRef(loadMore);
|
||||
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollParentRef.current;
|
||||
if (!el) return;
|
||||
let rafId = 0;
|
||||
const onScroll = () => {
|
||||
if (rafId) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = 0;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
|
||||
loadMoreRef.current();
|
||||
}
|
||||
});
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: songs.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
|
||||
|
||||
return (
|
||||
<section className="virtual-song-list-section">
|
||||
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
|
||||
<div className="virtual-song-list-toolbar">
|
||||
<div className="virtual-song-list-search">
|
||||
<SearchIcon size={16} className="virtual-song-list-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="input virtual-song-list-search-input"
|
||||
placeholder={t('tracks.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="virtual-song-list-search-clear"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="virtual-song-list-meta">
|
||||
{songs.length > 0 && (
|
||||
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmptyBrowse ? (
|
||||
<div className="virtual-song-list-empty">
|
||||
{emptyBrowseText ?? t('tracks.browseUnsupported')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SongListHeader />
|
||||
<div ref={scrollParentRef} className="virtual-song-list-scroll">
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const song = songs[vi.index];
|
||||
if (!song) return null;
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SongRow
|
||||
song={song}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="virtual-song-list-loading">
|
||||
<div className="spinner" style={{ width: 18, height: 18 }} />
|
||||
<span>{t('common.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+237
-33
@@ -7,7 +7,14 @@ function fmt(s: number): string {
|
||||
}
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */
|
||||
const WAVE_BIN_COUNT = 500;
|
||||
/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */
|
||||
const WAVE_MIX_MEAN = 0.7;
|
||||
const WAVE_MIX_MAX = 0.3;
|
||||
const SEG_COUNT = 60;
|
||||
const FLAT_WAVE_NORM = 0.06;
|
||||
const WAVE_MORPH_MS = 1000;
|
||||
|
||||
// ── animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -96,6 +103,59 @@ export function makeHeights(trackId: string): Float32Array {
|
||||
|
||||
// ── draw functions ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFlatWaveHeights(): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
h.fill(FLAT_WAVE_NORM);
|
||||
return h;
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
const x = Math.max(0, Math.min(1, t));
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
function binsToHeights(src: number[]): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
const n = src.length;
|
||||
if (n === WAVE_BIN_COUNT * 2) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
|
||||
const maxNorm = Number(src[idx]) / 255;
|
||||
const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255;
|
||||
const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm;
|
||||
h[i] = Math.max(0.08, Math.min(1, v));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
if (n === WAVE_BIN_COUNT) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
|
||||
const v = src[idx];
|
||||
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n));
|
||||
const v = src[idx];
|
||||
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (Math.abs(a[i] - b[i]) > eps) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waveformBarThickness(logicalH: number, norm: number): number {
|
||||
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
|
||||
return Math.max(1, safeNorm * logicalH);
|
||||
}
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
@@ -108,9 +168,38 @@ function drawWaveform(
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
|
||||
// before played/buffered — otherwise there is no visible playhead.
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
|
||||
}
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (w > 0) {
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +211,7 @@ function drawWaveform(
|
||||
ctx.fillStyle = unplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
@@ -132,7 +221,7 @@ function drawWaveform(
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
@@ -140,29 +229,14 @@ function drawWaveform(
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Fade both edges to transparent using destination-in gradient mask
|
||||
const fadeW = Math.min(22, w * 0.07);
|
||||
const mask = ctx.createLinearGradient(0, 0, w, 0);
|
||||
mask.addColorStop(0, 'transparent');
|
||||
mask.addColorStop(fadeW / w, 'black');
|
||||
mask.addColorStop(1 - fadeW / w, 'black');
|
||||
mask.addColorStop(1, 'transparent');
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = mask;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
@@ -700,7 +774,8 @@ export function drawSeekbar(
|
||||
) {
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
@@ -727,27 +802,45 @@ export function SeekbarPreview({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
let heights: Float32Array | null = null;
|
||||
if (style === 'waveform') {
|
||||
if (style === 'truewave' || style === 'pseudowave') {
|
||||
heights = makeHeights('seekbar-preview-demo');
|
||||
}
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
let rafId: number | null = null;
|
||||
let pollId: number | null = null;
|
||||
const stop = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pollId !== null) {
|
||||
window.clearTimeout(pollId);
|
||||
pollId = null;
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
if (document.hidden || window.__psyHidden) {
|
||||
pollId = window.setTimeout(() => {
|
||||
pollId = null;
|
||||
tick();
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
t += 0.016;
|
||||
animState.time = t;
|
||||
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
|
||||
const buffered = Math.min(1, progress + 0.18);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered, animState);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
|
||||
tick();
|
||||
return () => stop();
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
@@ -801,6 +894,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const SEEK_COMMIT_GUARD_MS = 900;
|
||||
const SEEK_COMMIT_MIN_HOLD_MS = 320;
|
||||
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||
@@ -811,6 +907,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
@@ -824,8 +921,81 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
heightsRef.current = null;
|
||||
return;
|
||||
}
|
||||
heightsRef.current = makeHeights(trackId);
|
||||
}, [trackId]);
|
||||
// Pseudowave is the deterministic per-track-ID variant — no analysis needed,
|
||||
// no morph animation, no flat-fallback. It just sits there looking like a
|
||||
// waveform.
|
||||
if (seekbarStyle === 'pseudowave') {
|
||||
heightsRef.current = makeHeights(trackId);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && !ANIMATED_STYLES.has(seekbarStyle)) {
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (waveformBins && waveformBins.length > 0) {
|
||||
const h = binsToHeights(waveformBins);
|
||||
const prev = heightsRef.current;
|
||||
if (!prev || prev.length !== BAR_COUNT) {
|
||||
heightsRef.current = h;
|
||||
return;
|
||||
}
|
||||
if (heightsNearlyEqual(prev, h, 0.02)) {
|
||||
heightsRef.current = h;
|
||||
return;
|
||||
}
|
||||
const from = new Float32Array(prev);
|
||||
const to = h;
|
||||
const startedAt = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||
const next = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||
}
|
||||
heightsRef.current = next;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
}
|
||||
if (p < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
if (heightsRef.current?.length === BAR_COUNT) {
|
||||
const current = heightsRef.current;
|
||||
let isAlreadyFlat = true;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
|
||||
isAlreadyFlat = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAlreadyFlat) return;
|
||||
const from = new Float32Array(current);
|
||||
const to = makeFlatWaveHeights();
|
||||
const startedAt = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||
const next = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||
}
|
||||
heightsRef.current = next;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
}
|
||||
if (p < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
// No analysis bins yet: render 500 flat bars immediately.
|
||||
heightsRef.current = makeFlatWaveHeights();
|
||||
}, [trackId, waveformBins, seekbarStyle]);
|
||||
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
@@ -835,6 +1005,15 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
// While user drags, keep the local preview stable. External progress ticks
|
||||
// during streaming/recovery would otherwise fight the cursor and flicker.
|
||||
if (isDragging.current) return;
|
||||
const pendingCommit = pendingCommittedSeekRef.current;
|
||||
if (pendingCommit) {
|
||||
const ageMs = Date.now() - pendingCommit.setAtMs;
|
||||
if (ageMs < SEEK_COMMIT_MIN_HOLD_MS) return;
|
||||
const matched = Math.abs(state.progress - pendingCommit.fraction) <= SEEK_COMMIT_PROGRESS_EPS;
|
||||
const expired = ageMs > SEEK_COMMIT_GUARD_MS;
|
||||
if (!matched && !expired) return;
|
||||
pendingCommittedSeekRef.current = null;
|
||||
}
|
||||
progressRef.current = state.progress;
|
||||
bufferedRef.current = state.buffered;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
@@ -844,12 +1023,17 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial draw for static styles when style or track changes.
|
||||
// Initial draw for static styles when style, track, or waveform payload changes.
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
}, [seekbarStyle, trackId]);
|
||||
}, [
|
||||
seekbarStyle,
|
||||
trackId,
|
||||
waveformBins,
|
||||
duration,
|
||||
]);
|
||||
|
||||
// rAF loop — animated styles only.
|
||||
useEffect(() => {
|
||||
@@ -857,14 +1041,32 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
animStateRef.current = makeAnimState();
|
||||
let rafId: number;
|
||||
let rafId: number | null = null;
|
||||
let pollId: number | null = null;
|
||||
const stop = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pollId !== null) {
|
||||
window.clearTimeout(pollId);
|
||||
pollId = null;
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
if (document.hidden || window.__psyHidden) {
|
||||
pollId = window.setTimeout(() => {
|
||||
pollId = null;
|
||||
tick();
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
tick();
|
||||
return () => stop();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer.
|
||||
@@ -894,6 +1096,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const seekRef = useRef(seek);
|
||||
seekRef.current = seek;
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
|
||||
|
||||
// Preview a 0–1 fraction while dragging: draw immediately for 1:1
|
||||
// responsiveness; the actual seek is committed on mouseup.
|
||||
@@ -910,6 +1113,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const fraction = pendingSeekRef.current;
|
||||
if (fraction === null) return;
|
||||
pendingSeekRef.current = null;
|
||||
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
|
||||
seekRef.current(fraction);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
||||
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
|
||||
AudioLines,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItemMeta {
|
||||
@@ -17,9 +18,11 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
tracks: { icon: AudioLines, labelKey: 'sidebar.tracks', to: '/tracks', section: 'library' },
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
|
||||
luckyMix: { icon: Sparkles, labelKey: 'sidebar.feelingLucky', to: '/lucky-mix', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Main scroll element wrapping `<Routes />` in App (overlay scrollbar). */
|
||||
export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport';
|
||||
@@ -128,6 +128,8 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!state.payload) return;
|
||||
|
||||
let ended = false;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// preventDefault stops the browser from treating the mouse movement as
|
||||
// a text-selection drag, which causes element highlighting and
|
||||
@@ -136,38 +138,63 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
// Clear any residual selection (from the pre-threshold phase).
|
||||
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
|
||||
const endDrag = (dispatchDrop: boolean) => {
|
||||
if (ended || !stateRef.current.payload) return;
|
||||
ended = true;
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
// Dispatch a custom event so drop targets can react.
|
||||
// The payload is in `detail`.
|
||||
const evt = new CustomEvent('psy-drop', {
|
||||
bubbles: true,
|
||||
detail: stateRef.current.payload,
|
||||
});
|
||||
// Find element under cursor
|
||||
const el = document.elementFromPoint(
|
||||
stateRef.current.position.x,
|
||||
stateRef.current.position.y,
|
||||
);
|
||||
if (el) el.dispatchEvent(evt);
|
||||
if (dispatchDrop) {
|
||||
const evt = new CustomEvent('psy-drop', {
|
||||
bubbles: true,
|
||||
detail: stateRef.current.payload,
|
||||
});
|
||||
const el = document.elementFromPoint(
|
||||
stateRef.current.position.x,
|
||||
stateRef.current.position.y,
|
||||
);
|
||||
if (el) el.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
setState({ payload: null, position: { x: 0, y: 0 } });
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp);
|
||||
const onUp = () => endDrag(true);
|
||||
/** Wayland: webview may not get `mouseup` when the pointer leaves the surface — clear the ghost without a drop. */
|
||||
const onBlur = () => endDrag(false);
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) endDrag(false);
|
||||
};
|
||||
const onPointerCancel = () => endDrag(false);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
endDrag(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
document.addEventListener('pointerup', onUp, true);
|
||||
document.addEventListener('pointercancel', onPointerCancel, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
|
||||
// Add a class so CSS can show grab cursor and suppress selection
|
||||
document.body.classList.add('psy-dragging');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
document.removeEventListener('pointerup', onUp, true);
|
||||
document.removeEventListener('pointercancel', onPointerCancel, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
document.body.classList.remove('psy-dragging');
|
||||
};
|
||||
}, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [state.payload]);
|
||||
|
||||
const ctxValue: DragDropContextValue = {
|
||||
startDrag,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
@@ -77,12 +78,17 @@ export function useConnectionStatus() {
|
||||
}, [check]);
|
||||
|
||||
const server = useAuthStore(s => s.getActiveServer());
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const serverName = useMemo(
|
||||
() => (server ? serverListDisplayLabel(server, servers) : ''),
|
||||
[server, servers],
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
isRetrying,
|
||||
retry,
|
||||
isLan: server ? isLanUrl(server.url) : false,
|
||||
serverName: server?.name ?? '',
|
||||
serverName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
/**
|
||||
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
|
||||
*
|
||||
* Single source of truth for the gate — previously this logic was inlined in
|
||||
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
|
||||
* the sidebarNavReorder filter. All call sites share the same three-way
|
||||
* predicate:
|
||||
* 1. User hasn't hidden it via the Settings toggle.
|
||||
* 2. AudioMuse is enabled for the active server (feature depends on
|
||||
* audiomuse-backed similar-track quality).
|
||||
* 3. An active server exists at all.
|
||||
*
|
||||
* Callers that additionally care about the "split vs hub" navigation mode
|
||||
* should combine this with `randomNavMode === 'separate'` explicitly — that's
|
||||
* an orthogonal UI placement concern, not an availability concern.
|
||||
*/
|
||||
export function isLuckyMixAvailable(args: {
|
||||
activeServerId: string | null | undefined;
|
||||
audiomuseByServer: Record<string, boolean>;
|
||||
showLuckyMixMenu: boolean;
|
||||
}): boolean {
|
||||
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
|
||||
if (!showLuckyMixMenu) return false;
|
||||
if (!activeServerId) return false;
|
||||
return Boolean(audiomuseByServer[activeServerId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook form — subscribes to the three authStore slices the predicate
|
||||
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
|
||||
* toggle on/off) re-renders the caller automatically.
|
||||
*/
|
||||
export function useLuckyMixAvailable(): boolean {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
|
||||
}
|
||||
+29
-2
@@ -8,6 +8,7 @@ import { fetchLyricsPlus, hasWordSync } from '../api/lyricsplus';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '../utils/lyricsPersistentCache';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
|
||||
@@ -38,7 +39,9 @@ export interface CachedLyrics {
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// Session-level cache — survives tab switches and component unmount/remount.
|
||||
// L1 cache: RAM, survives tab switches and component remount within a session.
|
||||
// L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on
|
||||
// L1 miss so the common case (jumping back to a recent track) stays fully sync.
|
||||
export const lyricsCache = new Map<string, CachedLyrics>();
|
||||
|
||||
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
||||
@@ -103,7 +106,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
|
||||
const store = (entry: CachedLyrics) => {
|
||||
const applyEntry = (entry: CachedLyrics) => {
|
||||
if (cancelled) return;
|
||||
lyricsCache.set(currentTrack.id, entry);
|
||||
setSyncedLines(entry.syncedLines);
|
||||
@@ -114,6 +117,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const store = (entry: CachedLyrics) => {
|
||||
if (cancelled) return;
|
||||
applyEntry(entry);
|
||||
// Persist for the next session (fire-and-forget — failures are silent).
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
putCachedLyrics(lyricsCacheKey(serverId, currentTrack.id), entry);
|
||||
};
|
||||
|
||||
// For offline / hot-cached tracks we have the file locally — read SYLT /
|
||||
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
|
||||
// Fast path: both store lookups are synchronous; returns false immediately
|
||||
@@ -243,6 +254,22 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
if (cancelled) return;
|
||||
if (await fetchEmbedded()) return;
|
||||
|
||||
// L2: IndexedDB — re-hydrates RAM cache without a network roundtrip.
|
||||
// Skip for 'lyricsplus' mode since the persisted entry might be from
|
||||
// the standard pipeline (no word-level sync) and the user explicitly
|
||||
// wants a fresh lyricsplus attempt.
|
||||
if (lyricsMode !== 'lyricsplus') {
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const persisted = await getCachedLyrics(lyricsCacheKey(serverId, currentTrack.id));
|
||||
if (cancelled) return;
|
||||
if (persisted) {
|
||||
// Don't re-write to L2 (it's already there); just hydrate RAM + UI.
|
||||
lyricsCache.set(currentTrack.id, persisted);
|
||||
applyEntry(persisted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// YouLyPlus mode: try lyricsplus first, silent fallback to standard.
|
||||
if (lyricsMode === 'lyricsplus') {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { getSong } from '../api/subsonic';
|
||||
import {
|
||||
readOrbitState,
|
||||
writeOrbitHeartbeat,
|
||||
} from '../utils/orbit';
|
||||
import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit';
|
||||
|
||||
/**
|
||||
* Orbit — guest-side tick hook.
|
||||
*
|
||||
* Mounted at the app shell; only does work when the local store says we're
|
||||
* a guest in an active session. Two independent timers:
|
||||
*
|
||||
* - **State read** (2.5 s): pull the canonical state from the session
|
||||
* playlist's comment and mirror it into the store. Detect session end
|
||||
* or own kick and tear down.
|
||||
* - **Heartbeat** (10 s): refresh the guest's own outbox playlist
|
||||
* comment so the host's participant sweep sees the user as alive.
|
||||
*
|
||||
* Reads are best-effort; a transient Navidrome outage just delays state
|
||||
* updates by a tick or two. The session continues locally as long as the
|
||||
* playback engine has the current track loaded.
|
||||
*/
|
||||
|
||||
const STATE_READ_TICK_MS = 2_500;
|
||||
const HEARTBEAT_TICK_MS = 10_000;
|
||||
/**
|
||||
* Host must be quiet (no state writes) for this long before we treat the
|
||||
* session as dead and auto-leave. Well above any normal network blip —
|
||||
* reconnects inside this window are silent. Tuned per user decision:
|
||||
* manual exits have priority, short reconnects never trigger auto-close.
|
||||
*/
|
||||
const HOST_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export function useOrbitGuest(): void {
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
|
||||
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
|
||||
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
|
||||
|
||||
/**
|
||||
* Last host playback state we *applied* to the local player. Compared
|
||||
* against the new tick to detect host-side flips (track change /
|
||||
* play-pause toggle) and against the local player's current state to
|
||||
* detect guest-side divergence (the guest paused or skipped on their own).
|
||||
*
|
||||
* Reset to null on (re-)activation so a fresh session re-syncs from scratch.
|
||||
*/
|
||||
const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null);
|
||||
|
||||
// ── State read + end/kick detection + auto-sync to host ──────────────
|
||||
useEffect(() => {
|
||||
if (!active || !sessionPlaylistId) return;
|
||||
|
||||
let cancelled = false;
|
||||
lastAppliedRef.current = null;
|
||||
|
||||
/**
|
||||
* Load `trackId` into the local player and seek to the host's live
|
||||
* position. Mirrors the host's `isPlaying` state — a guest joining a
|
||||
* paused host doesn't auto-start, a guest joining a playing host must
|
||||
* start. Best-effort; silent on miss.
|
||||
*
|
||||
* Seek + state-mirror is applied once the engine reports the target
|
||||
* track as `isPlaying` (polled up to 2 s), with a final fallback apply
|
||||
* past the deadline so a loading error doesn't leave the guest stuck
|
||||
* on a silent pause.
|
||||
*/
|
||||
const syncToHost = async (trackId: string, hostState: OrbitState): Promise<boolean> => {
|
||||
try {
|
||||
const song = await getSong(trackId);
|
||||
if (!song || cancelled) return false;
|
||||
const track = songToTrack(song);
|
||||
// Clamp fraction to [0, 0.99] — if the host's positionAt is unusually
|
||||
// stale, estimateLivePosition can overshoot the track duration and a
|
||||
// seek past the end would immediately trigger audio:ended.
|
||||
const calcFraction = () => {
|
||||
const targetMs = estimateLivePosition(hostState, Date.now());
|
||||
const targetSec = Math.max(0, targetMs / 1000);
|
||||
return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
|
||||
};
|
||||
const applyMirror = (): boolean => {
|
||||
const p = usePlayerStore.getState();
|
||||
if (cancelled || p.currentTrack?.id !== trackId) return false;
|
||||
p.seek(calcFraction());
|
||||
if (hostState.isPlaying && !p.isPlaying) p.resume();
|
||||
else if (!hostState.isPlaying && p.isPlaying) p.pause();
|
||||
return true;
|
||||
};
|
||||
|
||||
const player = usePlayerStore.getState();
|
||||
if (player.currentTrack?.id === trackId) {
|
||||
return applyMirror();
|
||||
}
|
||||
|
||||
player.playTrack(track, [track]);
|
||||
|
||||
// Poll until the engine has the track loaded; fall back to a blind
|
||||
// apply after 2 s so a stuck load doesn't leave us spinning forever.
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const deadline = Date.now() + 2000;
|
||||
const poll = () => {
|
||||
if (cancelled) { resolve(false); return; }
|
||||
const p = usePlayerStore.getState();
|
||||
const trackReady = p.currentTrack?.id === trackId;
|
||||
if (trackReady && p.isPlaying) { resolve(applyMirror()); return; }
|
||||
if (Date.now() >= deadline) { resolve(applyMirror()); return; }
|
||||
window.setTimeout(poll, 100);
|
||||
};
|
||||
window.setTimeout(poll, 100);
|
||||
});
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
const pull = async () => {
|
||||
const state = await readOrbitState(sessionPlaylistId);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!state) {
|
||||
// Session playlist is gone — almost always means the host ended the
|
||||
// session and the `ended:true` write was missed because we polled
|
||||
// after the subsequent playlist delete. Surface the same modal the
|
||||
// explicit `state.ended` branch does; the store still holds the last
|
||||
// known state so the modal can render the host + session name copy.
|
||||
// Outbox cleanup runs from the modal's OK handler via leaveOrbitSession.
|
||||
useOrbitStore.getState().setPhase('ended');
|
||||
return;
|
||||
}
|
||||
|
||||
useOrbitStore.getState().setState(state);
|
||||
|
||||
// Auto-leave after prolonged host silence. We keep polling as long as
|
||||
// state reads succeed (short reconnects are silent), but if the host
|
||||
// hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the
|
||||
// session as effectively dead and surface the exit modal. Manual exit
|
||||
// still works instantly — the bar's X button short-circuits this path.
|
||||
if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) {
|
||||
useOrbitStore.getState().setError('host-timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconcile pending guest suggestions against the host's *playable*
|
||||
// queue — NOT `state.queue`, which is the suggestion history (every
|
||||
// submission lands there immediately, even under manual-approval mode
|
||||
// where the host hasn't actually accepted the track yet).
|
||||
// `state.playQueue` is the host's real upcoming queue, so a trackId
|
||||
// appearing there (or as `currentTrack`) means the host has merged it.
|
||||
if (useOrbitStore.getState().pendingSuggestions.length > 0) {
|
||||
const landed = new Set<string>();
|
||||
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
|
||||
if (state.currentTrack) landed.add(state.currentTrack.trackId);
|
||||
useOrbitStore.getState().reconcilePendingSuggestions(landed);
|
||||
}
|
||||
|
||||
// Host signalled session end: surface via `phase`, let the UI handle
|
||||
// the modal. Outbox cleanup still happens via leaveOrbitSession().
|
||||
if (state.ended) {
|
||||
useOrbitStore.getState().setPhase('ended');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kicked / soft-removed: transition into the error phase with a
|
||||
// matching errorMessage so the UI can pick the right copy.
|
||||
const me = useAuthStore.getState().getActiveServer()?.username;
|
||||
if (me && state.kicked.includes(me)) {
|
||||
useOrbitStore.getState().setError('kicked');
|
||||
return;
|
||||
}
|
||||
// Soft-remove: only react to markers strictly newer than our own join
|
||||
// time, otherwise a stale marker from a prior session-life would
|
||||
// immediately bounce us out on rejoin.
|
||||
if (me && state.removed && state.removed.length > 0) {
|
||||
const joinedAt = useOrbitStore.getState().joinedAt ?? 0;
|
||||
const hit = state.removed.find(r => r.user === me && r.at > joinedAt);
|
||||
if (hit) {
|
||||
useOrbitStore.getState().setError('removed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-sync host playback into local player ──
|
||||
// Rules:
|
||||
// 1. First tick after activation → mirror host (initial join sync,
|
||||
// no need for the guest to click catch-up to get started).
|
||||
// 2. Track changed at host → guest follows ONLY if they haven't
|
||||
// locally diverged. A guest who hit pause should stay paused
|
||||
// even when the host moves to the next song; otherwise their
|
||||
// pause button silently un-does itself. If diverged, we just
|
||||
// advance the anchor so Catch Up stays the opt-in path.
|
||||
// 3. Same track, host flipped play/pause → mirror only if the local
|
||||
// player still matches our last-applied host state. If the guest
|
||||
// paused/resumed locally, we leave them alone — they have to
|
||||
// click catch-up to opt back in.
|
||||
const player = usePlayerStore.getState();
|
||||
const hostTrackId = state.currentTrack?.trackId ?? null;
|
||||
const hostPlaying = state.isPlaying;
|
||||
const last = lastAppliedRef.current;
|
||||
|
||||
if (!last) {
|
||||
// Initial sync: only record `last` *after* syncToHost actually
|
||||
// landed. If the first attempt loses the race (engine not ready,
|
||||
// stale audio state, network blip), a retry ticker below will try
|
||||
// again every 500 ms until it succeeds. Without this, the first
|
||||
// failed sync set `last` anyway and the guest was stuck on their
|
||||
// pre-join state until they clicked Catch Up.
|
||||
if (hostTrackId) {
|
||||
const ok = await syncToHost(hostTrackId, state);
|
||||
if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
} else {
|
||||
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
|
||||
}
|
||||
} else if (last.trackId !== hostTrackId) {
|
||||
const diverged = player.isPlaying !== last.isPlaying;
|
||||
if (diverged) {
|
||||
// Guest is running their own show (typically: paused while host
|
||||
// kept going). Do not load/start the host's new track — just
|
||||
// track the host state so the catch-up prompt stays accurate.
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
} else if (hostTrackId) {
|
||||
void syncToHost(hostTrackId, state);
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
} else {
|
||||
if (player.isPlaying) player.pause();
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
}
|
||||
} else if (last.isPlaying !== hostPlaying) {
|
||||
// Only mirror when the guest hasn't diverged. We compare against the
|
||||
// *last applied* host state, not the new one — divergence means the
|
||||
// local player no longer matches what we last pushed in.
|
||||
if (player.isPlaying === last.isPlaying) {
|
||||
if (hostPlaying) player.resume();
|
||||
else player.pause();
|
||||
}
|
||||
// Either way, advance the anchor so we don't keep retrying the same
|
||||
// flip every tick.
|
||||
lastAppliedRef.current = { trackId: last.trackId, isPlaying: hostPlaying };
|
||||
}
|
||||
};
|
||||
|
||||
// Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an
|
||||
// initial sync yet, fall back to the steady cadence once we're anchored.
|
||||
// Lets a failed first attempt retry quickly without spamming the network
|
||||
// for the lifetime of the session.
|
||||
let timer: number | null = null;
|
||||
const tick = async () => {
|
||||
timer = null;
|
||||
await pull();
|
||||
if (cancelled) return;
|
||||
const delay = lastAppliedRef.current === null ? 500 : STATE_READ_TICK_MS;
|
||||
timer = window.setTimeout(tick, delay);
|
||||
};
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [active, sessionPlaylistId]);
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!active || !outboxPlaylistId || !sessionId) return;
|
||||
const me = useAuthStore.getState().getActiveServer()?.username;
|
||||
if (!me) return;
|
||||
const outboxName = orbitOutboxPlaylistName(sessionId, me);
|
||||
|
||||
const beat = async () => {
|
||||
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
|
||||
catch { /* best-effort */ }
|
||||
};
|
||||
void beat();
|
||||
|
||||
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [active, outboxPlaylistId, sessionId]);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { getSong } from '../api/subsonic';
|
||||
import {
|
||||
writeOrbitState,
|
||||
writeOrbitHeartbeat,
|
||||
sweepGuestOutboxes,
|
||||
applyOutboxSnapshotsToState,
|
||||
maybeShuffleQueue,
|
||||
effectiveShuffleIntervalMs,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import {
|
||||
orbitOutboxPlaylistName,
|
||||
ORBIT_PLAY_QUEUE_LIMIT,
|
||||
type OrbitState,
|
||||
type OrbitQueueItem,
|
||||
} from '../api/orbit';
|
||||
import { showToast } from '../utils/toast';
|
||||
import i18n from '../i18n';
|
||||
|
||||
/**
|
||||
* Orbit — host-side tick hook.
|
||||
*
|
||||
* Mounted once at the app shell level; only does work when the local store
|
||||
* says we're the host of an active session. Two independent timers:
|
||||
*
|
||||
* - **State tick** (2.5 s): snapshot isPlaying + position + current track
|
||||
* from the player store, patch the local OrbitState, push to the
|
||||
* session playlist's comment.
|
||||
* - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's
|
||||
* comment with a fresh timestamp so the later-added participant
|
||||
* pipeline can treat the host symmetrically.
|
||||
*
|
||||
* Writes are best-effort — a transient Navidrome outage just means guests
|
||||
* see stale state for a tick or two and catch up on the next write.
|
||||
* Phase 2 does not yet consume anything from guests.
|
||||
*/
|
||||
|
||||
const STATE_TICK_MS = 2_500;
|
||||
const HEARTBEAT_TICK_MS = 10_000;
|
||||
|
||||
export function useOrbitHost(): void {
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
|
||||
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
|
||||
// Refs hold the last values we used to build the patch — cheap to
|
||||
// recompute against, no need to subscribe to every playerStore tick.
|
||||
const lastPushedAtRef = useRef(0);
|
||||
|
||||
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !sessionPlaylistId) return;
|
||||
|
||||
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
||||
const p = usePlayerStore.getState();
|
||||
const now = Date.now();
|
||||
return {
|
||||
isPlaying: p.isPlaying,
|
||||
positionMs: Math.round((p.currentTime ?? 0) * 1000),
|
||||
positionAt: now,
|
||||
currentTrack: p.currentTrack
|
||||
? {
|
||||
trackId: p.currentTrack.id,
|
||||
// Locally-initiated plays are marked as authored by the host.
|
||||
// Guest-suggested tracks that later become `currentTrack` will
|
||||
// carry their original attribution because the queue-consume
|
||||
// flow keeps the `addedBy` from the guest's outbox.
|
||||
addedBy: hostUsername,
|
||||
addedAt: now,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const pushState = async () => {
|
||||
const store = useOrbitStore.getState();
|
||||
const base = store.state;
|
||||
if (!base) return;
|
||||
|
||||
// 1) Sweep every guest outbox: new suggestions + fresh heartbeats.
|
||||
let afterSweep = base;
|
||||
try {
|
||||
const snaps = await sweepGuestOutboxes(base.sid, base.host);
|
||||
afterSweep = applyOutboxSnapshotsToState(base, snaps);
|
||||
} catch { /* best-effort; keep old participants and queue */ }
|
||||
|
||||
// 2) Merge newly-suggested items into the host's local play queue so
|
||||
// guest suggestions actually start playing alongside host-chosen
|
||||
// tracks. Must happen BEFORE the shuffle step so the merge decision
|
||||
// tracks `addedAt` (immutable) rather than list position.
|
||||
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
|
||||
|
||||
// 3) Shuffle check:
|
||||
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
|
||||
// suggestion list). It also bumps `lastShuffle` even when the list
|
||||
// is too small to reorder — that's the authoritative 15-min marker.
|
||||
// b) In parallel, we shuffle the *host's* upcoming play queue so the
|
||||
// mix the guests hear actually changes. `autoShuffle=false` skips
|
||||
// both.
|
||||
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
|
||||
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
|
||||
const afterShuffle = maybeShuffleQueue(afterSweep);
|
||||
if (shouldShuffleNow) {
|
||||
const before = usePlayerStore.getState().queue.length;
|
||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
|
||||
}
|
||||
|
||||
// 4) Overlay the host's live playback snapshot.
|
||||
const playerLive = usePlayerStore.getState();
|
||||
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
|
||||
// Map track id → original suggester (if any). State's `queue` carries
|
||||
// every suggestion we've ever seen this session, so it's the right
|
||||
// attribution source even after the track has been merged into the
|
||||
// host's player queue.
|
||||
const suggesterByTrack = new Map<string, string>();
|
||||
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
|
||||
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
|
||||
trackId: t.id,
|
||||
addedBy: suggesterByTrack.get(t.id) ?? base.host,
|
||||
}));
|
||||
const next: OrbitState = {
|
||||
...afterShuffle,
|
||||
...snapshotPlayerPatch(base.host),
|
||||
playQueue,
|
||||
playQueueTotal: upcoming.length,
|
||||
};
|
||||
|
||||
// 5) Commit locally + push remote.
|
||||
useOrbitStore.getState().setState(next);
|
||||
try {
|
||||
await writeOrbitState(sessionPlaylistId, next);
|
||||
lastPushedAtRef.current = Date.now();
|
||||
} catch { /* best-effort; next tick retries */ }
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve each not-yet-merged suggestion via `getSong` and append to the
|
||||
* player queue. Records a toast per successful append so the host notices
|
||||
* guest activity. Safe to call every tick — the set filter keeps it idempotent.
|
||||
*/
|
||||
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
|
||||
// Opt-out: host turned auto-approve off. Items still accumulate in
|
||||
// `OrbitState.queue` and show up in the guest view / approval list —
|
||||
// they just don't flow into the host's actual play queue yet.
|
||||
const store = useOrbitStore.getState();
|
||||
const settings = store.state?.settings;
|
||||
if (settings && settings.autoApprove === false) return;
|
||||
|
||||
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
|
||||
// must not flow through the merge pipeline again — otherwise the tick
|
||||
// would duplicate the track into the upcoming queue. Declined items
|
||||
// stay out too; merged items are the existing dedup anchor.
|
||||
const hostUser = store.state?.host;
|
||||
const mergedKeys = new Set(store.mergedSuggestionKeys);
|
||||
const declinedKeys = new Set(store.declinedSuggestionKeys);
|
||||
const pending = items.filter(q =>
|
||||
q.addedBy !== hostUser
|
||||
&& !mergedKeys.has(suggestionKey(q))
|
||||
&& !declinedKeys.has(suggestionKey(q))
|
||||
);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
|
||||
const resolved = await Promise.all(pending.map(async q => {
|
||||
try {
|
||||
const song = await getSong(q.trackId);
|
||||
return song ? { q, track: songToTrack(song) } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
|
||||
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
|
||||
if (toEnqueue.length === 0) {
|
||||
// Mark the failed lookups as seen anyway so we don't keep retrying
|
||||
// every tick for a track the server can't serve.
|
||||
markAllAsMerged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sprinkle each track at a random spot inside the upcoming range so
|
||||
// guest suggestions interleave with host-picked tracks rather than
|
||||
// pile up at the end (where they'd never play until the 15-min shuffle).
|
||||
const player = usePlayerStore.getState();
|
||||
for (const { track } of toEnqueue) {
|
||||
const live = usePlayerStore.getState();
|
||||
const from = Math.max(0, live.queueIndex + 1);
|
||||
const to = live.queue.length;
|
||||
const span = Math.max(1, to - from + 1);
|
||||
const pos = from + Math.floor(Math.random() * span);
|
||||
player.enqueueAt([track], pos);
|
||||
}
|
||||
markAllAsMerged();
|
||||
|
||||
// Friendly nudge per sweep, not per track — bundled toast if >1.
|
||||
if (toEnqueue.length === 1) {
|
||||
const { q, track } = toEnqueue[0];
|
||||
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
|
||||
} else {
|
||||
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Immediate push on mount so guests see fresh state without waiting
|
||||
// a full tick after the host comes online.
|
||||
void pushState();
|
||||
|
||||
const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [active, sessionPlaylistId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !outboxPlaylistId || !sessionId) return;
|
||||
const server = useOrbitStore.getState().state?.host;
|
||||
if (!server) return;
|
||||
const outboxName = orbitOutboxPlaylistName(sessionId, server);
|
||||
|
||||
const pushHeartbeat = async () => {
|
||||
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
|
||||
catch { /* best-effort */ }
|
||||
};
|
||||
void pushHeartbeat();
|
||||
|
||||
const id = window.setInterval(() => { void pushHeartbeat(); }, HEARTBEAT_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [active, outboxPlaylistId, sessionId]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
suggestOrbitTrack,
|
||||
hostEnqueueToOrbit,
|
||||
evaluateOrbitSuggestGate,
|
||||
OrbitSuggestBlockedError,
|
||||
} from '../utils/orbit';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
/**
|
||||
* Shared behaviour for song rows that in "normal mode" swallow a full list
|
||||
* into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites,
|
||||
* ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch).
|
||||
*
|
||||
* In an active Orbit session this is too destructive — the list would
|
||||
* propagate to every guest's player. Instead:
|
||||
*
|
||||
* - `queueHint()` — show a toast telling the user to double-click.
|
||||
* Safe to call on every single-click; 220 ms debounce
|
||||
* suppresses the pileup that browsers emit before a
|
||||
* dblclick fires.
|
||||
* - `addTrackToOrbit(songId)` — cancel any pending hint and add just that
|
||||
* one track: suggestOrbitTrack for guests,
|
||||
* hostEnqueueToOrbit for the host.
|
||||
*
|
||||
* `orbitActive` is the gate — when false, callers should skip the hint and
|
||||
* run their original bulk-play path unchanged.
|
||||
*/
|
||||
export function useOrbitSongRowBehavior() {
|
||||
const { t } = useTranslation();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const orbitActive = orbitRole === 'host' || orbitRole === 'guest';
|
||||
const clickTimerRef = useRef<number | null>(null);
|
||||
|
||||
const queueHint = useCallback(() => {
|
||||
if (clickTimerRef.current !== null) return;
|
||||
clickTimerRef.current = window.setTimeout(() => {
|
||||
clickTimerRef.current = null;
|
||||
showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info');
|
||||
}, 220);
|
||||
}, [t]);
|
||||
|
||||
const addTrackToOrbit = useCallback((songId: string) => {
|
||||
if (clickTimerRef.current !== null) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
}
|
||||
if (orbitRole === 'guest') {
|
||||
const gate = evaluateOrbitSuggestGate();
|
||||
if (!gate.allowed && gate.reason === 'muted') {
|
||||
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
|
||||
return;
|
||||
}
|
||||
suggestOrbitTrack(songId)
|
||||
.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');
|
||||
}
|
||||
});
|
||||
} else if (orbitRole === 'host') {
|
||||
hostEnqueueToOrbit(songId)
|
||||
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||
}
|
||||
}, [orbitRole, t]);
|
||||
|
||||
return { orbitActive, queueHint, addTrackToOrbit };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user