mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 061c96a89f | |||
| 31d6e5bd77 | |||
| df91396ba1 | |||
| fb927c5a2e | |||
| 7ee03e917b | |||
| 21f65915db | |||
| 33ba1dfa28 | |||
| f60dabbd31 | |||
| f8bc39036a | |||
| f9f39ccfc2 | |||
| a8317f5877 | |||
| 46bbae873e | |||
| ba43ed867a | |||
| 832bacb569 | |||
| b9093883a4 | |||
| 77edfaa867 | |||
| bac0417e00 | |||
| f30920bfeb | |||
| 8e72e7084b | |||
| dcd356aee7 | |||
|
4f188be792
|
|||
|
cd1417b604
|
|||
| ee437fca5a | |||
| e102320e32 |
@@ -103,15 +103,51 @@ jobs:
|
||||
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 }}
|
||||
|
||||
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:
|
||||
@@ -160,12 +196,13 @@ jobs:
|
||||
\( -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
|
||||
# and refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
|
||||
# (nixpkgs pin). Commits the refreshed files back to `main` when they change.
|
||||
# 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.
|
||||
#
|
||||
# Tarball publishing / Cachix upload are intentionally out of scope here —
|
||||
# those will live in a dedicated workflow tied to a binary cache setup.
|
||||
# The refreshed lock/hash files are committed back to `main` when they change.
|
||||
verify-nix:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -183,6 +220,15 @@ jobs:
|
||||
- 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: |
|
||||
@@ -202,7 +248,7 @@ jobs:
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
|
||||
- name: verify nix build
|
||||
- name: verify nix build + push to Cachix
|
||||
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
|
||||
- name: commit + push refreshed lock and hash (if changed)
|
||||
|
||||
+29
-1
@@ -5,7 +5,35 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **⚠️ Note for Windows users:** This is the last release with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
|
||||
> **⚠️ Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
|
||||
|
||||
## [1.34.15] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
|
||||
## [1.34.14] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
|
||||
|
||||
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
|
||||
|
||||
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
|
||||
|
||||
---
|
||||
|
||||
## [1.34.13] - 2026-04-17
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/ckVPGPMS">
|
||||
<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>
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776169885,
|
||||
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-0000000000000000000000000000000000000000000="
|
||||
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
|
||||
}
|
||||
|
||||
Generated
+22
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.11",
|
||||
"version": "1.34.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.11",
|
||||
"version": "1.34.13",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
@@ -814,6 +816,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/golos-text": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/golos-text/-/golos-text-5.2.8.tgz",
|
||||
"integrity": "sha512-cLT8Gu9tSQTOjfPY+qnrqQwafUUJkZu0s9hTbQbtaeknTyV36c/FQc5hvTRvgeUOT4cp/Xf0dkHdZatwj3g2Nw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
@@ -895,6 +906,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/unbounded": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.2.8.tgz",
|
||||
"integrity": "sha512-DWC/HEdNNbjMH6ngeeCAPExKMsedoY+pV3ZnRXzFcAzXuGHB6dEwsXNVQ4fiuuYMGguq9TSAEUat4Oy5prdwWQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.34.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// macOS-only for now — Windows + Linux are added once their signing pipelines
|
||||
// (Certum cert for Windows, native package managers for Linux) are wired up.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
|
||||
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
// A real minisign .sig file is multi-line and ~200+ chars.
|
||||
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
|
||||
function validateSignature(sig, platform, sigFile) {
|
||||
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
|
||||
` Got: ${sig}\n` +
|
||||
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
|
||||
);
|
||||
}
|
||||
if (sig.length < 80) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
validateSignature(signature, platform, sigFile);
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Generated
+193
-3
@@ -69,6 +69,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -968,6 +977,17 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1316,6 +1336,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2511,7 +2542,10 @@ version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2703,6 +2737,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2985,6 +3025,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -3114,6 +3166,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3163,7 +3229,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3396,6 +3462,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -3581,7 +3653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.13"
|
||||
version = "1.34.15"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -3608,10 +3680,12 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"windows 0.58.0",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
@@ -3832,6 +3906,15 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3950,15 +4033,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4119,6 +4207,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
@@ -4601,7 +4716,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -5030,6 +5145,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -5287,6 +5413,39 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
@@ -6234,6 +6393,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
@@ -7138,6 +7306,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
@@ -7384,6 +7562,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.13"
|
||||
version = "1.34.15"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -40,6 +40,7 @@ biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
@@ -54,6 +55,8 @@ libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart"
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -90,6 +90,35 @@ fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebKitGTK: `enable-smooth-scrolling` also drives deferred / kinetic wheel scrolling.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, enabled: bool) -> Result<(), String> {
|
||||
win.with_webview(move |platform| {
|
||||
use webkit2gtk::{SettingsExt, WebViewExt};
|
||||
if let Some(settings) = platform.inner().settings() {
|
||||
settings.set_enable_smooth_scrolling(enabled);
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
|
||||
#[tauri::command]
|
||||
fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
linux_webkit_apply_smooth_scrolling(&win, enabled)?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (enabled, app_handle);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
@@ -2396,6 +2425,7 @@ pub fn run() {
|
||||
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
||||
.manage(TrayState::default())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -2578,6 +2608,7 @@ pub fn run() {
|
||||
cli_publish_server_list,
|
||||
cli_publish_search_results,
|
||||
set_window_decorations,
|
||||
set_linux_webkit_smooth_scrolling,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
register_global_shortcut,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.34.15",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -31,6 +31,17 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUWFjOQo=",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
+13
-13
@@ -127,6 +127,7 @@ function AppShell() {
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -138,6 +139,7 @@ function AppShell() {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
@@ -150,6 +152,11 @@ function AppShell() {
|
||||
invoke('set_window_decorations', { enabled }).catch(() => {});
|
||||
}, [useCustomTitlebar, isTilingWm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
|
||||
}, [linuxWebkitKineticScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
@@ -341,6 +348,7 @@ function AppShell() {
|
||||
/>
|
||||
)}
|
||||
<main className="main-content">
|
||||
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
@@ -389,6 +397,7 @@ function AppShell() {
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{!isMobile && (
|
||||
<div
|
||||
@@ -919,8 +928,6 @@ export default function App() {
|
||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const setUiScale = useFontStore(s => s.setUiScale);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -931,17 +938,10 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
|
||||
// Reset any stored non-100% value so users aren't stuck at a broken scale.
|
||||
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
|
||||
useEffect(() => {
|
||||
if (uiScale !== 1.0) setUiScale(1.0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = String(uiScale);
|
||||
}, [uiScale]);
|
||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
||||
// because they live in separate grid cells. Document-level zoom is not used
|
||||
// — it broke portal positioning and Tauri window measurement.
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
|
||||
@@ -166,6 +166,42 @@ export default function AppUpdater() {
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||
// relaunches. No manual "open the DMG" step needed.
|
||||
if (IS_MACOS) {
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: 0 });
|
||||
setDlError('');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
setDlError(t('common.updaterErrorMsg'));
|
||||
setDlState('error');
|
||||
return;
|
||||
}
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await update.downloadAndInstall(event => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
setDlProgress({ bytes: 0, total });
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setDlProgress({ bytes: downloaded, total });
|
||||
} else if (event.event === 'Finished') {
|
||||
setDlState('done');
|
||||
}
|
||||
});
|
||||
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -21,19 +22,197 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window:
|
||||
// activeIdx=0 → railY=+2×slotH (line 0 at slot 2)
|
||||
// activeIdx=2 → railY=0 (line 2 at center)
|
||||
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
|
||||
// ─── Apple Music-style fullscreen lyrics ─────────────────────────────────────
|
||||
// Full-screen scrollable list. Active line auto-scrolls to ~35% from top.
|
||||
// Word-sync runs imperatively (no React re-renders on every time tick).
|
||||
// User scroll pauses auto-scroll for 4 s then resumes.
|
||||
|
||||
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, plainLyrics, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
: (syncedLines as LrcLine[] | null);
|
||||
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lineSrc! : [];
|
||||
|
||||
// React state only for the active line index — changes are infrequent.
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const activeIdxRef = useRef(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const springRef = useRef<SpringScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevWord = useRef({ line: -1, word: -1 });
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Create/destroy the SpringScroller when the container mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (el) {
|
||||
springRef.current = new SpringScroller(el, 0.1, 0.78);
|
||||
} else {
|
||||
springRef.current?.stop();
|
||||
springRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset everything on track change.
|
||||
useEffect(() => {
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
prevWord.current = { line: -1, word: -1 };
|
||||
activeIdxRef.current = -1;
|
||||
setActiveIdx(-1);
|
||||
springRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Subscribe to playback time — only triggers React setState when line changes.
|
||||
useEffect(() => {
|
||||
if (!hasSynced) return;
|
||||
const apply = (time: number) => {
|
||||
const ls = linesRef.current;
|
||||
if (!ls.length) return;
|
||||
const idx = ls.reduce((acc, line, i) => time >= line.time ? i : acc, -1);
|
||||
if (idx !== activeIdxRef.current) {
|
||||
activeIdxRef.current = idx;
|
||||
setActiveIdx(idx);
|
||||
}
|
||||
};
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [hasSynced, currentTrack?.id]);
|
||||
|
||||
// Spring-scroll active line to ~35% from the top of the container.
|
||||
useEffect(() => {
|
||||
if (activeIdx < 0 || isUserScroll.current) return;
|
||||
const el = lineRefs.current[activeIdx];
|
||||
const box = containerRef.current;
|
||||
if (!el || !box || !springRef.current) return;
|
||||
springRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
||||
}, [activeIdx]);
|
||||
|
||||
// Word-sync: imperative DOM updates, zero React re-renders per tick.
|
||||
useEffect(() => {
|
||||
wordRefs.current = [];
|
||||
prevWord.current = { line: -1, word: -1 };
|
||||
}, [currentTrack?.id, useWords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useWords) return;
|
||||
const lines = wordLines as WordLyricsLine[];
|
||||
const apply = (time: number) => {
|
||||
let li = -1;
|
||||
for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; }
|
||||
let wi = -1;
|
||||
if (li >= 0) {
|
||||
const ws = lines[li].words;
|
||||
for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; }
|
||||
}
|
||||
const prev = prevWord.current;
|
||||
if (prev.line === li && prev.word === wi) return;
|
||||
if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line])
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'fsa-lyric-word';
|
||||
if (li >= 0 && wordRefs.current[li]) {
|
||||
const ws = wordRefs.current[li];
|
||||
for (let j = 0; j < ws.length; j++)
|
||||
ws[j].className = j < wi ? 'fsa-lyric-word played' : j === wi ? 'fsa-lyric-word active' : 'fsa-lyric-word';
|
||||
}
|
||||
prevWord.current = { line: li, word: wi };
|
||||
};
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
const handleUserScroll = useCallback(() => {
|
||||
// Stop spring animation so it doesn't fight the user's scroll.
|
||||
springRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
|
||||
if (!target || duration <= 0) return;
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
if (!currentTrack || loading) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsa-lyrics-container"
|
||||
ref={setContainerRef}
|
||||
onWheel={handleUserScroll}
|
||||
onTouchMove={handleUserScroll}
|
||||
onClick={handleClick}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="fsa-lyrics-top-pad" />
|
||||
|
||||
{hasSynced && (useWords
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0
|
||||
? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fsa-lyric-word"
|
||||
ref={el => {
|
||||
if (!wordRefs.current[i]) wordRefs.current[i] = [];
|
||||
if (el) wordRefs.current[i][j] = el;
|
||||
}}
|
||||
>{w.text}</span>
|
||||
))
|
||||
: (line.text || '\u00A0')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!hasSynced && plainLyrics && (
|
||||
<div className="fsa-plain-lyrics">
|
||||
{plainLyrics.split('\n').map((line, i) => (
|
||||
<p key={i} className="fsa-plain-line">{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fsa-lyrics-bottom-pad" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Classic 5-line rail lyrics (original "Rail" style) ──────────────────────
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
|
||||
// Static-only hides the FS overlay entirely — the 5-line rail UX is inherently
|
||||
// time-driven; without sync the pane view is the correct surface.
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
@@ -65,10 +244,8 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
// Per-word DOM refs keyed by line index; imperative updates skip re-renders
|
||||
// on progress ticks. Only populated when `useWords` is true.
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
|
||||
|
||||
useEffect(() => {
|
||||
wordRefs.current = [];
|
||||
@@ -78,41 +255,27 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
useEffect(() => {
|
||||
if (!useWords) return;
|
||||
const lines = wordLines as WordLyricsLine[];
|
||||
|
||||
const apply = (time: number) => {
|
||||
let lineIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (time >= lines[i].time) lineIdx = i;
|
||||
else break;
|
||||
}
|
||||
let wordIdx = -1;
|
||||
if (lineIdx >= 0) {
|
||||
const words = lines[lineIdx].words;
|
||||
for (let j = 0; j < words.length; j++) {
|
||||
if (time >= words[j].time) wordIdx = j;
|
||||
else break;
|
||||
}
|
||||
let li = -1;
|
||||
for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; }
|
||||
let wi = -1;
|
||||
if (li >= 0) {
|
||||
const ws = lines[li].words;
|
||||
for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; }
|
||||
}
|
||||
const prev = prevWord.current;
|
||||
if (prev.line === lineIdx && prev.word === wordIdx) return;
|
||||
// Clear previous line's word classes.
|
||||
if (prev.line !== lineIdx && prev.line >= 0 && wordRefs.current[prev.line]) {
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'fs-lyric-word';
|
||||
if (prev.line === li && prev.word === wi) return;
|
||||
if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line])
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'fsr-lyric-word';
|
||||
if (li >= 0 && wordRefs.current[li]) {
|
||||
const ws = wordRefs.current[li];
|
||||
for (let j = 0; j < ws.length; j++)
|
||||
ws[j].className = j < wi ? 'fsr-lyric-word played' : j === wi ? 'fsr-lyric-word active' : 'fsr-lyric-word';
|
||||
}
|
||||
if (lineIdx >= 0 && wordRefs.current[lineIdx]) {
|
||||
const ws = wordRefs.current[lineIdx];
|
||||
for (let j = 0; j < ws.length; j++) {
|
||||
ws[j].className = j < wordIdx ? 'fs-lyric-word played'
|
||||
: j === wordIdx ? 'fs-lyric-word active'
|
||||
: 'fs-lyric-word';
|
||||
}
|
||||
}
|
||||
prevWord.current = { line: lineIdx, word: wordIdx };
|
||||
prevWord.current = { line: li, word: wi };
|
||||
};
|
||||
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
return unsub;
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
@@ -120,9 +283,9 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fs-lyrics-overlay" aria-hidden="true">
|
||||
<div className="fsr-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fs-lyrics-rail"
|
||||
className="fsr-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
@@ -130,27 +293,25 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0 ? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fs-lyric-word"
|
||||
className="fsr-lyric-word"
|
||||
ref={el => {
|
||||
if (!wordRefs.current[i]) wordRefs.current[i] = [];
|
||||
if (el) wordRefs.current[i][j] = el;
|
||||
}}
|
||||
>
|
||||
{w.text}
|
||||
</span>
|
||||
>{w.text}</span>
|
||||
)) : (line.text || '\u00A0')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
@@ -273,20 +434,40 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const s = usePlayerStore.getState();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
// Cache the last applied values so we can skip DOM writes when nothing
|
||||
// changed. The store subscription fires on every state change (queue,
|
||||
// volume, currentTrack…), not just progress, so without this guard we
|
||||
// were writing identical width/textContent strings dozens of times per
|
||||
// second — each one triggers a style invalidation in WebKitGTK.
|
||||
let lastTime = -1;
|
||||
let lastPct = -1;
|
||||
let lastBufW = -1;
|
||||
let lastProg = -1;
|
||||
|
||||
return usePlayerStore.subscribe(state => {
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
const apply = (s: ReturnType<typeof usePlayerStore.getState>) => {
|
||||
const pct = s.progress * 100;
|
||||
const bufW = Math.max(pct, s.buffered * 100);
|
||||
const sec = s.currentTime | 0;
|
||||
if (sec !== lastTime) {
|
||||
lastTime = sec;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
}
|
||||
if (pct !== lastPct) {
|
||||
lastPct = pct;
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
}
|
||||
if (bufW !== lastBufW) {
|
||||
lastBufW = bufW;
|
||||
if (bufRef.current) bufRef.current.style.width = `${bufW}%`;
|
||||
}
|
||||
if (s.progress !== lastProg) {
|
||||
lastProg = s.progress;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
}
|
||||
};
|
||||
|
||||
apply(usePlayerStore.getState());
|
||||
return usePlayerStore.subscribe(apply);
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
@@ -316,6 +497,77 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Lyrics settings popover — shown above the mic button ────────────────────
|
||||
interface FsLyricsMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
}
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
|
||||
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// setTimeout(0) defers listener registration past the current click cycle
|
||||
// so the button click that opens the panel doesn't immediately close it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="fslm-panel" ref={panelRef}>
|
||||
{/* Toggle row */}
|
||||
<div className="fslm-row">
|
||||
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
|
||||
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLyrics}
|
||||
onChange={e => setLyrics(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Style selector — dimmed when lyrics are off */}
|
||||
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
|
||||
{(['rail', 'apple'] as const).map(style => (
|
||||
<button
|
||||
key={style}
|
||||
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
|
||||
onClick={() => setStyle(style)}
|
||||
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
|
||||
>
|
||||
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const { t } = useTranslation();
|
||||
@@ -428,8 +680,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
||||
@@ -445,6 +699,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
getCachedUrl(url, key).catch(() => {});
|
||||
}, [nextCoverArt]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -492,6 +750,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
@@ -505,7 +764,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
@@ -516,8 +775,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics overlay — upper-left quadrant, above cluster */}
|
||||
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
@@ -575,15 +837,18 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
@@ -6,21 +6,25 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-sync highlighting is driven imperatively via `usePlayerStore.subscribe`
|
||||
* so the whole lyrics block doesn't re-render on every 500 ms progress tick.
|
||||
* Active-line scroll is still React-state-driven — lines change infrequently.
|
||||
* Apple Music-style scroll: active line scrolls to ~35% from top.
|
||||
* User scrolling pauses auto-scroll for 4 s then resumes.
|
||||
* Word-sync and line highlighting are imperative (no React re-renders per tick).
|
||||
*/
|
||||
export default function LyricsPane({ currentTrack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { syncedLines, wordLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
|
||||
const { staticOnly } = useAuthStore(useShallow(s => ({ staticOnly: s.lyricsStaticOnly })));
|
||||
const { staticOnly, sidebarLyricsStyle } = useAuthStore(useShallow(s => ({
|
||||
staticOnly: s.lyricsStaticOnly,
|
||||
sidebarLyricsStyle: s.sidebarLyricsStyle,
|
||||
})));
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const hasSynced = !staticOnly && !useWords && syncedLines !== null && syncedLines.length > 0;
|
||||
@@ -28,19 +32,52 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevActive = useRef({ line: -1, word: -1 });
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const springRef = useRef<SpringScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevActive = useRef({ line: -1, word: -1 });
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Attach/detach SpringScroller when the pane mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
springRef.current?.stop();
|
||||
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
|
||||
}, []);
|
||||
|
||||
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
|
||||
const handleUserScroll = useCallback(() => {
|
||||
springRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
}, []);
|
||||
|
||||
// Scroll active line into view.
|
||||
// Apple style: spring-animate to ~35% from top.
|
||||
// Classic style: native scrollIntoView center.
|
||||
const scrollToLine = useCallback((el: HTMLDivElement) => {
|
||||
if (isUserScroll.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (sidebarLyricsStyle === 'apple' && springRef.current) {
|
||||
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [sidebarLyricsStyle]);
|
||||
|
||||
// Reset refs when track changes.
|
||||
useEffect(() => {
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
prevActive.current = { line: -1, word: -1 };
|
||||
springRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Imperative tracker for line+word highlighting. Subscribes directly to the
|
||||
// store to skip React render cycles for 500 ms progress ticks.
|
||||
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
|
||||
useEffect(() => {
|
||||
if (!useWords && !hasSynced) return;
|
||||
|
||||
@@ -64,7 +101,6 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const prev = prevActive.current;
|
||||
if (prev.line === lineIdx && prev.word === wordIdx) return;
|
||||
|
||||
// Update line classes.
|
||||
if (prev.line !== lineIdx) {
|
||||
if (prev.line >= 0) {
|
||||
const el = lineRefs.current[prev.line];
|
||||
@@ -74,16 +110,14 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const el = lineRefs.current[lineIdx];
|
||||
if (el) {
|
||||
el.className = lineClass(lineIdx, lineIdx);
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
scrollToLine(el);
|
||||
}
|
||||
}
|
||||
// Reset all word classes on previous line.
|
||||
if (useWords && prev.line >= 0 && wordRefs.current[prev.line]) {
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'lyrics-word';
|
||||
}
|
||||
}
|
||||
|
||||
// Update word classes inside the active line.
|
||||
if (useWords && lineIdx >= 0 && wordRefs.current[lineIdx]) {
|
||||
const ws = wordRefs.current[lineIdx];
|
||||
for (let j = 0; j < ws.length; j++) {
|
||||
@@ -96,11 +130,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
prevActive.current = { line: lineIdx, word: wordIdx };
|
||||
};
|
||||
|
||||
// Prime once from the current store value.
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
return unsub;
|
||||
}, [useWords, hasSynced, wordLines, syncedLines]);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
|
||||
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
@@ -120,14 +152,18 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
? t('player.lyricsSourceLyricsplus')
|
||||
: null;
|
||||
|
||||
// Static-only + synced or words available → render line list as static text.
|
||||
const renderAsStatic = staticOnly && (
|
||||
(syncedLines !== null && syncedLines.length > 0) ||
|
||||
(wordLines !== null && wordLines.length > 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
<div
|
||||
className="lyrics-pane"
|
||||
ref={setContainerRef}
|
||||
onWheel={handleUserScroll}
|
||||
onTouchMove={handleUserScroll}
|
||||
>
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
||||
|
||||
|
||||
@@ -538,6 +538,8 @@ export const deTranslation = {
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
useCustomTitlebar: 'Eigene Titelleiste',
|
||||
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
|
||||
linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.',
|
||||
discordAppleCovers: 'Cover über Apple Music für Discord laden',
|
||||
discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.',
|
||||
discordOptions: 'Erweiterte Discord-Optionen',
|
||||
@@ -673,6 +675,16 @@ export const deTranslation = {
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
fsLyricsStyle: 'Liedtext-Stil',
|
||||
fsLyricsStyleRail: 'Schiene',
|
||||
fsLyricsStyleRailDesc: 'Klassische 5-Zeilen-Schiene.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Vollbild-Scrollliste.',
|
||||
sidebarLyricsStyle: 'Text-Scroll-Stil',
|
||||
sidebarLyricsStyleClassic: 'Klassisch',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktive Zeile wird zentriert.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
seekbarWaveform: 'Wellenform',
|
||||
|
||||
@@ -540,6 +540,8 @@ export const enTranslation = {
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
useCustomTitlebar: 'Custom title bar',
|
||||
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
|
||||
linuxWebkitSmoothScroll: 'Smooth wheel (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.',
|
||||
discordAppleCovers: 'Fetch covers from Apple Music for Discord',
|
||||
discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.',
|
||||
discordOptions: 'Advanced Discord options',
|
||||
@@ -672,6 +674,16 @@ export const enTranslation = {
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Fullscreen Player',
|
||||
fsLyricsStyle: 'Lyrics style',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Classic 5-line sliding rail.',
|
||||
fsLyricsStyleApple: 'Scrolling',
|
||||
fsLyricsStyleAppleDesc: 'Full-screen scrollable list.',
|
||||
sidebarLyricsStyle: 'Lyrics scroll style',
|
||||
sidebarLyricsStyleClassic: 'Classic',
|
||||
sidebarLyricsStyleClassicDesc: 'Scroll active line to center.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
|
||||
@@ -541,6 +541,8 @@ export const esTranslation = {
|
||||
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
|
||||
useCustomTitlebar: 'Barra de título personalizada',
|
||||
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
|
||||
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
|
||||
discordAppleCovers: 'Obtener portadas de Apple Music para Discord',
|
||||
discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.',
|
||||
discordOptions: 'Opciones avanzadas de Discord',
|
||||
@@ -676,6 +678,16 @@ export const esTranslation = {
|
||||
fsShowArtistPortrait: 'Mostrar foto del artista',
|
||||
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
|
||||
fsPortraitDim: 'Oscurecimiento de foto',
|
||||
fsLyricsStyle: 'Estilo de letra',
|
||||
fsLyricsStyleRail: 'Carril',
|
||||
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
|
||||
fsLyricsStyleApple: 'Desplazamiento',
|
||||
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
|
||||
sidebarLyricsStyle: 'Estilo de desplazamiento de letra',
|
||||
sidebarLyricsStyleClassic: 'Clásico',
|
||||
sidebarLyricsStyleClassicDesc: 'La línea activa se centra.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
||||
seekbarStyle: 'Estilo de Barra de Progreso',
|
||||
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
||||
seekbarWaveform: 'Forma de Onda',
|
||||
|
||||
@@ -534,6 +534,8 @@ export const frTranslation = {
|
||||
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord',
|
||||
@@ -671,6 +673,16 @@ export const frTranslation = {
|
||||
fsShowArtistPortrait: "Afficher la photo de l'artiste",
|
||||
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
|
||||
fsPortraitDim: "Assombrissement de la photo",
|
||||
fsLyricsStyle: "Style des paroles",
|
||||
fsLyricsStyleRail: "Rail",
|
||||
fsLyricsStyleRailDesc: "Rail glissant classique de 5 lignes.",
|
||||
fsLyricsStyleApple: "Défilement",
|
||||
fsLyricsStyleAppleDesc: "Liste déroulante plein écran.",
|
||||
sidebarLyricsStyle: "Style de défilement des paroles",
|
||||
sidebarLyricsStyleClassic: "Classique",
|
||||
sidebarLyricsStyleClassicDesc: "La ligne active est centrée.",
|
||||
sidebarLyricsStyleApple: "Apple Music-like",
|
||||
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
seekbarWaveform: 'Forme d\'onde',
|
||||
|
||||
@@ -533,6 +533,8 @@ export const nbTranslation = {
|
||||
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
|
||||
minimizeToTray: 'Minimer til oppgavelinjen',
|
||||
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
|
||||
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
|
||||
discordAppleCovers: 'Hent covere fra Apple Music til Discord',
|
||||
@@ -670,6 +672,16 @@ export const nbTranslation = {
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
fsLyricsStyle: 'Tekststil',
|
||||
fsLyricsStyleRail: 'Skinner',
|
||||
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
|
||||
fsLyricsStyleApple: 'Rulling',
|
||||
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
|
||||
sidebarLyricsStyle: 'Rullestil for tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassisk',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
seekbarWaveform: 'Bølgeform',
|
||||
|
||||
@@ -533,6 +533,8 @@ export const nlTranslation = {
|
||||
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord',
|
||||
@@ -670,6 +672,16 @@ export const nlTranslation = {
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
fsLyricsStyle: 'Tekststijl',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Klassieke 5-regels schuifrail.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Volledig scherm scrolllijst.',
|
||||
sidebarLyricsStyle: 'Scrollstijl voor tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassiek',
|
||||
sidebarLyricsStyleClassicDesc: 'Actieve regel wordt gecentreerd.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
seekbarWaveform: 'Golfvorm',
|
||||
|
||||
@@ -555,6 +555,8 @@ export const ruTranslation = {
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
|
||||
linuxWebkitSmoothScroll: 'Плавное колесо (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
@@ -698,6 +700,16 @@ export const ruTranslation = {
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
fsLyricsStyle: 'Стиль отображения текста',
|
||||
fsLyricsStyleRail: 'Рельс',
|
||||
fsLyricsStyleRailDesc: 'Классическая рамка из 5 строк.',
|
||||
fsLyricsStyleApple: 'Прокрутка',
|
||||
fsLyricsStyleAppleDesc: 'Полноэкранный список с прокруткой.',
|
||||
sidebarLyricsStyle: 'Стиль прокрутки текста',
|
||||
sidebarLyricsStyleClassic: 'Классический',
|
||||
sidebarLyricsStyleClassicDesc: 'Активная строка центрируется.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
|
||||
@@ -529,6 +529,8 @@ export const zhTranslation = {
|
||||
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
linuxWebkitSmoothScroll: '滚轮平滑(Linux)',
|
||||
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
|
||||
@@ -666,6 +668,16 @@ export const zhTranslation = {
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
fsLyricsStyle: '歌词显示样式',
|
||||
fsLyricsStyleRail: '轨道',
|
||||
fsLyricsStyleRailDesc: '经典5行滑动轨道。',
|
||||
fsLyricsStyleApple: '滚动',
|
||||
fsLyricsStyleAppleDesc: '全屏可滚动列表。',
|
||||
sidebarLyricsStyle: '歌词滚动样式',
|
||||
sidebarLyricsStyleClassic: '经典',
|
||||
sidebarLyricsStyleClassicDesc: '活动行居中显示。',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
|
||||
+104
-38
@@ -120,6 +120,7 @@ const CONTRIBUTORS = [
|
||||
'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)',
|
||||
'ReplayGain values in Queue tech strip (PR #196)',
|
||||
'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
|
||||
'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -135,6 +136,8 @@ const CONTRIBUTORS = [
|
||||
'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
|
||||
'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
|
||||
'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
|
||||
'Apple Music-style scrolling lyrics with spring-physics scroll for fullscreen player and sidebar; per-style controls (PR #205)',
|
||||
'Golos Text and Unbounded fonts with Cyrillic support (PR #206)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1136,6 +1139,25 @@ export default function Settings() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{IS_LINUX && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.linuxWebkitKineticScroll}
|
||||
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
@@ -1739,50 +1761,56 @@ export default function Settings() {
|
||||
<h2>{t('settings.uiScaleTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{/* TODO: UI scaling is being reworked — disabled until fixed */}
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
|
||||
Interface scaling is currently being reworked and will be available in a future update.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
max={1.5}
|
||||
step={0.05}
|
||||
value={fontStore.uiScale}
|
||||
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ position: 'relative', height: 24 }}>
|
||||
{[80, 90, 100, 110, 125, 150].map(p => {
|
||||
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
|
||||
const active = Math.round(fontStore.uiScale * 100) === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${pct}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1812,6 +1840,8 @@ export default function Settings() {
|
||||
{ id: 'lexend', label: 'Lexend' },
|
||||
{ id: 'geist', label: 'Geist' },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||
{ id: 'golos-text', label: 'Golos Text' },
|
||||
{ id: 'unbounded', label: 'Unbounded' },
|
||||
] as { id: FontId; label: string }[]).find(f => f.id === fontStore.font)?.label ?? fontStore.font
|
||||
}</span>
|
||||
<ChevronDown size={14} style={{ color: 'var(--text-muted)', transform: fontPickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
|
||||
@@ -1832,6 +1862,8 @@ export default function Settings() {
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
|
||||
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
@@ -1884,6 +1916,40 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.sidebarLyricsStyle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['classic', 'apple'] as const).map(style => {
|
||||
const key = style === 'classic' ? 'Classic' : 'Apple';
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => auth.setSidebarLyricsStyle(style)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 10,
|
||||
border: `2px solid ${auth.sidebarLyricsStyle === style ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: auth.sidebarLyricsStyle === style ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-secondary)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
@@ -65,6 +66,8 @@ interface AuthState {
|
||||
discordTemplateState: string;
|
||||
discordTemplateLargeText: string;
|
||||
useCustomTitlebar: boolean;
|
||||
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
|
||||
linuxWebkitKineticScroll: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
lyricsServerFirst: boolean;
|
||||
enableNeteaselyrics: boolean;
|
||||
@@ -81,6 +84,10 @@ interface AuthState {
|
||||
*/
|
||||
lyricsStaticOnly: boolean;
|
||||
showFullscreenLyrics: boolean;
|
||||
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
|
||||
fsLyricsStyle: 'rail' | 'apple';
|
||||
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
|
||||
sidebarLyricsStyle: 'classic' | 'apple';
|
||||
showFsArtistPortrait: boolean;
|
||||
/** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */
|
||||
fsPortraitDim: number;
|
||||
@@ -205,6 +212,7 @@ interface AuthState {
|
||||
setDiscordTemplateState: (v: string) => void;
|
||||
setDiscordTemplateLargeText: (v: string) => void;
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setLinuxWebkitKineticScroll: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
setEnableNeteaselyrics: (v: boolean) => void;
|
||||
@@ -212,6 +220,8 @@ interface AuthState {
|
||||
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
|
||||
setLyricsStaticOnly: (v: boolean) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
|
||||
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
@@ -308,6 +318,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
discordTemplateState: '{album}',
|
||||
discordTemplateLargeText: '{album}',
|
||||
useCustomTitlebar: false,
|
||||
linuxWebkitKineticScroll: true,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
enableNeteaselyrics: false,
|
||||
@@ -315,6 +326,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
lyricsMode: 'standard',
|
||||
lyricsStaticOnly: false,
|
||||
showFullscreenLyrics: true,
|
||||
fsLyricsStyle: 'rail',
|
||||
sidebarLyricsStyle: 'classic',
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
@@ -435,6 +448,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
|
||||
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||
@@ -442,6 +456,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
setLyricsMode: (v) => set({ lyricsMode: v }),
|
||||
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
|
||||
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
|
||||
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
@@ -620,6 +636,20 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// One-time: older builds could persist smooth=false as the default. Force smooth on once
|
||||
// so updates do not leave users on discrete scrolling; after this flag exists, only an
|
||||
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
|
||||
const wheelSmoothMigrationKey = 'psysonic-linux-webkit-smooth-v1';
|
||||
let wheelSmoothOneTime: { linuxWebkitKineticScroll?: boolean } = {};
|
||||
if (IS_LINUX) {
|
||||
try {
|
||||
if (!localStorage.getItem(wheelSmoothMigrationKey)) {
|
||||
wheelSmoothOneTime = { linuxWebkitKineticScroll: true };
|
||||
localStorage.setItem(wheelSmoothMigrationKey, '1');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useAuthStore.setState({
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
@@ -629,6 +659,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
),
|
||||
...conflictingLegacyState,
|
||||
...lyricsSourcesMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono';
|
||||
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono' | 'golos-text' | 'unbounded';
|
||||
|
||||
interface FontState {
|
||||
font: FontId;
|
||||
|
||||
+398
-88
@@ -2645,8 +2645,9 @@
|
||||
.lyrics-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
/* scroll-behavior intentionally left to auto — JS drives smooth scroll imperatively */
|
||||
padding: 16px 16px 8px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.lyrics-pane-empty {
|
||||
@@ -2667,57 +2668,71 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0 16px;
|
||||
padding: 24px 0 40px; /* top pad lets first line start below centre, bottom lets last line scroll up */
|
||||
}
|
||||
|
||||
.lyrics-line {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
transition: color 0.25s;
|
||||
line-height: 1.55;
|
||||
cursor: default;
|
||||
transform-origin: center center;
|
||||
/* All transitions are compositor-friendly */
|
||||
transition:
|
||||
color 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
text-shadow 350ms ease;
|
||||
transform: scale(0.92);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
transform: scale(0.88);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.lyrics-line.active {
|
||||
color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.lyrics-line:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Word-sync (karaoke) mode — per-word highlight inside the active line.
|
||||
The parent `.lyrics-line.active` colour is overridden: only `.active` / `.played`
|
||||
words carry accent tint, the remaining words stay at the line's base colour. */
|
||||
/* Word-sync: neutralise line colour so only individual words pop */
|
||||
.lyrics-word-synced .lyrics-line {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lyrics-word-synced .lyrics-line.active {
|
||||
color: var(--text-secondary); /* line colour neutralised; words decide */
|
||||
color: var(--text-muted);
|
||||
text-shadow: none;
|
||||
}
|
||||
.lyrics-word-synced .lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lyrics-word {
|
||||
display: inline;
|
||||
transition: color 0.18s ease;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
.lyrics-word.played {
|
||||
color: var(--accent);
|
||||
opacity: 0.65;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.lyrics-word.active {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
@@ -3502,51 +3517,296 @@
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* ── Lyrics overlay — upper-left quadrant, strictly 5 lines tall ── */
|
||||
/* Slot = 6vh → 5 × 6vh = 30vh. JS reads window.innerHeight * 0.06. */
|
||||
.fs-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 15vh; /* pushed down into the quadrant, clear of close button */
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
right: 52%; /* stay out of portrait area */
|
||||
height: 30vh; /* exactly 5 × 6vh — never grows, never overlaps cluster */
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout style; /* isolate reflows from rest of page */
|
||||
/* ── Apple Music-style fullscreen lyrics ─────────────────────────────────────
|
||||
Full-screen scrollable list. Active line scrolls to ~35% from top.
|
||||
User scroll pauses auto-scroll for 4 s. Word-sync via imperative DOM updates. */
|
||||
|
||||
/* Fade first and last slot (each 6/30 = 20% of container) */
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 20%,
|
||||
#000 80%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 20%,
|
||||
#000 80%,
|
||||
transparent 100%
|
||||
);
|
||||
.fsa-lyrics-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
padding: 0 clamp(28px, 5vw, 72px);
|
||||
/* pointer-events on children only — container itself scrollable */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Synced rail — smooth slide, no abrupt jumps */
|
||||
.fs-lyrics-rail {
|
||||
.fsa-lyrics-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Top padding — clears the close button (44 px) + some breathing room */
|
||||
.fsa-lyrics-top-pad {
|
||||
height: 80px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Bottom padding — lets last line scroll to ~35% from top even on short tracks */
|
||||
.fsa-lyrics-bottom-pad {
|
||||
height: 60vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Line base styles ── */
|
||||
.fsa-lyric-line {
|
||||
/* Fixed font-size avoids layout reflow on active-line switch.
|
||||
Visual "size" difference is done via transform: scale below. */
|
||||
font-size: clamp(1.4rem, 3.2vh, 3rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
margin-bottom: 0.45em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
transform-origin: left center;
|
||||
/* All visual changes are compositor-friendly (color, text-shadow, transform) */
|
||||
transition:
|
||||
color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
text-shadow 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(0.9);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fsa-lyric-line:hover {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Past: subtly smaller and more faded */
|
||||
.fsa-lyric-line.fsal-past {
|
||||
color: rgba(255, 255, 255, 0.16);
|
||||
transform: scale(0.86);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Active: full scale, bright, glowing — font-weight snaps (no layout cost on same size) */
|
||||
.fsa-lyric-line.fsal-active {
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
text-shadow:
|
||||
0 0 48px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 25%),
|
||||
0 2px 18px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* Word-sync — active line dims base so only lit words pop */
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.fsa-lyric-word {
|
||||
display: inline;
|
||||
white-space: pre; /* preserve trailing spaces from lyricsplus tokens */
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word.played {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word.active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 32px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 10%);
|
||||
}
|
||||
|
||||
/* Plain (unsynced) lyrics */
|
||||
.fsa-plain-lyrics {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fsa-plain-line {
|
||||
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
margin: 0 0 0.2em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Gradient fades — top and bottom chrome ──────────────────────────────── */
|
||||
|
||||
/* Top fade: covers close button area; pointer-events none so button stays clickable */
|
||||
.fsa-fade-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(6, 6, 14, 0.92) 0%,
|
||||
rgba(6, 6, 14, 0.5) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Bottom fade: sits above lyrics (same z-index, later in DOM) but under cluster */
|
||||
.fsa-fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 45vh;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(6, 6, 14, 0.55) 40%,
|
||||
rgba(6, 6, 14, 0.88) 72%,
|
||||
rgba(6, 6, 14, 0.96) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout overrides when lyrics mode is active ────────────────────────── */
|
||||
|
||||
/* Hide portrait — lyrics use the full width */
|
||||
.fs-player[data-lyrics] .fs-portrait-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrim is redundant without the portrait — hide it */
|
||||
.fs-player[data-lyrics] .fs-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Lyrics settings popover (above mic button) ─────────────────────────────── */
|
||||
|
||||
/* The panel itself — floated above the mic button.
|
||||
Rendered inside position:relative wrapper (the button wrapper).
|
||||
z-index: 9 sits above the backdrop (8). */
|
||||
.fslm-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
width: 230px;
|
||||
z-index: 9;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
animation: fslmIn 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
@keyframes fslmIn {
|
||||
from { opacity: 0; transform: scale(0.88) translateY(6px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Toggle row */
|
||||
.fslm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fslm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Style selector */
|
||||
.fslm-style-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.fslm-disabled .fslm-style-row,
|
||||
.fslm-style-row.fslm-disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fslm-style-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fslm-style-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fslm-style-active {
|
||||
/* accent colors set inline via accentColor prop */
|
||||
}
|
||||
|
||||
.fslm-style-name {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fslm-style-desc {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down toward the button */
|
||||
.fslm-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ── Classic 5-line rail (Rail style) ───────────────────────────────────────── */
|
||||
|
||||
.fsr-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 15vh;
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
right: 52%;
|
||||
height: 30vh;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout style;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.fsr-lyrics-rail {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
will-change: transform;
|
||||
transition: transform 500ms ease-out;
|
||||
}
|
||||
|
||||
/* Each lyric slot is exactly 6vh — matches JS window.innerHeight * 0.06.
|
||||
Fixed height (not min-height) is intentional: keeps rail math correct
|
||||
regardless of whether the text wraps or not. 2 wrapped lines of 2vh
|
||||
font at 1.4 line-height = 5.6vh, comfortably inside the 6vh slot. */
|
||||
.fs-lyric-line {
|
||||
.fsr-lyric-line {
|
||||
height: 6vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3560,50 +3820,57 @@
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
/* font-weight intentionally NOT transitioned — triggers layout reflow on every frame */
|
||||
transition: color 280ms ease, text-shadow 280ms ease, transform 280ms ease;
|
||||
transform-origin: left center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-lyric-line:hover {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.fsr-lyric-line:hover { color: rgba(255, 255, 255, 0.5); }
|
||||
|
||||
.fs-lyric-line.fsl-past {
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.fsr-lyric-line.fsrl-past { color: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
/* Active: emphasis via scale + glow; font-weight snaps instantly (no layout-reflow transition) */
|
||||
.fs-lyric-line.fsl-active {
|
||||
.fsr-lyric-line.fsrl-active {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
transform: scaleX(1.015); /* subtle compositor-only emphasis, no layout cost */
|
||||
transform: scaleX(1.015);
|
||||
}
|
||||
|
||||
/* Fullscreen word-sync (karaoke). Parent .fsl-active base colour stays dim so
|
||||
only the .active/.played words pop. */
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word {
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-shadow: none;
|
||||
}
|
||||
.fs-lyric-word {
|
||||
|
||||
.fsr-lyric-word {
|
||||
display: inline;
|
||||
/* Preserve the trailing space the lyricsplus API embeds in each syllabus
|
||||
token (`"Gina "`, `"dreams "`, …). Without this the flex formatting
|
||||
context collapses it and words run together. */
|
||||
white-space: pre;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease;
|
||||
}
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word.played {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word.active {
|
||||
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.played { color: rgba(255, 255, 255, 0.75); }
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
|
||||
html.no-compositing .fsr-lyrics-rail { will-change: auto; }
|
||||
|
||||
/* Apple-style FS lyrics — strip GPU-only effects.
|
||||
Without compositing, transform: scale() on every line forces a per-line
|
||||
software repaint on each active-line change, and the 48 px / 32 px blur
|
||||
text-shadows repaint on every word tick. Replace with opacity + colour,
|
||||
which are cheap text-only repaints. */
|
||||
html.no-compositing .fsa-lyric-line {
|
||||
transform: none;
|
||||
transition: color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
html.no-compositing .fsa-lyric-line.fsal-past { transform: none; }
|
||||
html.no-compositing .fsa-lyric-line.fsal-active { transform: none; text-shadow: none; }
|
||||
html.no-compositing .fsa-lyric-line.fsal-active .fsa-lyric-word.active {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* ── no-compositing overrides ─────────────────────────────────────────────────
|
||||
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
|
||||
@@ -3629,10 +3896,19 @@ html.no-compositing .fs-portrait {
|
||||
}
|
||||
|
||||
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
|
||||
pointer-events: none on wrap means the overlay never blocks clicks. */
|
||||
pointer-events: none on wrap means the overlay never blocks clicks.
|
||||
Also drop translateZ(0) — useless GPU layer hint without a compositor. */
|
||||
html.no-compositing .fs-portrait-wrap {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* FS art crossfade: opacity transition compositing is GPU-cheap, software-expensive.
|
||||
Snap instantly between covers; visually a hard cut, but the previous layer is
|
||||
replaced before the user can really notice. */
|
||||
html.no-compositing .fs-art {
|
||||
transition: none;
|
||||
}
|
||||
html.no-compositing .fs-portrait-wrap::before {
|
||||
content: '';
|
||||
@@ -3648,23 +3924,18 @@ html.no-compositing .fs-portrait-wrap::before {
|
||||
);
|
||||
}
|
||||
|
||||
/* Lyrics overlay: just remove the fade — no replacement needed */
|
||||
html.no-compositing .fs-lyrics-overlay {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
|
||||
|
||||
/* Lyrics rail: remove will-change */
|
||||
html.no-compositing .fs-lyrics-rail {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Mesh blobs: stop animations — in software mode each frame composites surfaces
|
||||
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
|
||||
displays this saturates the CPU and drops the player to ~10 FPS.
|
||||
Static gradients are preserved; only the slow pan animation is removed. */
|
||||
/* Mesh blobs: stop animations + flatten to a solid dark fill.
|
||||
Even static, the 130 × 130 % radial-gradient with color-mix() is recomputed
|
||||
on every paint, and the `transition: background 400ms` on accent change
|
||||
re-rasterises the giant surface for nearly half a second per track switch.
|
||||
Atmosphere is sacrificed; the dynamic accent is preserved on title /
|
||||
seekbar / controls via their own var(--dynamic-fs-accent) bindings. */
|
||||
html.no-compositing .fs-mesh-blob {
|
||||
animation: none;
|
||||
background: #06060e;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Seekbar played bar: remove box-shadow — its width changes on every playback
|
||||
@@ -3673,6 +3944,45 @@ html.no-compositing .fs-seekbar-played {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Track title: drop the 40 px text-shadow glow — paints a giant blur on a
|
||||
clamp(28–68 px) font and re-rasterises whenever anything around it
|
||||
redraws (cluster updates, seekbar tick, dynamic accent change). */
|
||||
html.no-compositing .fs-track-title {
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.65);
|
||||
transition: color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Album art wrap: replace the accent-coloured outer glow with a flat
|
||||
shadow. The 28 px blur was repainting every accent change. */
|
||||
html.no-compositing .fs-art-wrap {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.07);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Play button: drop accent glow + hover filter. Keep flat dark shadow only. */
|
||||
html.no-compositing .fs-btn-play {
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
html.no-compositing .fs-btn-play:hover {
|
||||
filter: none;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* Rail-style FS lyrics: same problems as Apple style — 28 px text-shadow
|
||||
blur on every active line + every active word, plus per-line transform
|
||||
scaleX. Strip them; rail still scrolls fine via the parent translateY. */
|
||||
html.no-compositing .fsr-lyric-line {
|
||||
transition: color 280ms ease;
|
||||
transform: none;
|
||||
}
|
||||
html.no-compositing .fsr-lyric-line.fsrl-active {
|
||||
text-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-popup {
|
||||
position: absolute;
|
||||
|
||||
@@ -940,6 +940,20 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Scoped UI scaling target. Sidebar / queue / player / titlebar live in
|
||||
sibling grid cells of .app-shell and stay at 1:1. The wrapper inherits
|
||||
.main-content's flex column layout so .content-header (fixed height) and
|
||||
.content-body (flex: 1, scroll) keep working unchanged.
|
||||
Zoom is applied via inline style only when uiScale !== 1, so the default
|
||||
case has no extra layer or layout cost. */
|
||||
.main-content-zoom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+550
-171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Spring-based scroll animation — iOS / Apple Music feel.
|
||||
*
|
||||
* Uses a critically-damped spring model driven by rAF:
|
||||
* velocity += (target − position) × stiffness
|
||||
* velocity *= damping
|
||||
* position += velocity
|
||||
*
|
||||
* Tuning:
|
||||
* stiffness 0.04 – 0.10 → lower = slower / more fluid
|
||||
* damping 0.80 – 0.88 → lower = more bounce; higher = overdamped / snappy
|
||||
* maxVelocity → caps initial lurch when target is far away
|
||||
*
|
||||
* A single SpringScroller instance per container avoids fighting rAF loops
|
||||
* when the target changes before the previous animation finishes — calling
|
||||
* scrollTo() mid-flight just updates the target and the running loop picks it up.
|
||||
*/
|
||||
export class SpringScroller {
|
||||
private container : HTMLElement;
|
||||
private target = 0;
|
||||
private velocity = 0;
|
||||
private rafId: number | null = null;
|
||||
|
||||
private readonly stiffness : number;
|
||||
private readonly damping : number;
|
||||
private readonly maxVelocity: number;
|
||||
|
||||
constructor(
|
||||
container : HTMLElement,
|
||||
stiffness = 0.065, // gentle pull
|
||||
damping = 0.84, // smooth settle, no oscillation
|
||||
maxVelocity = 28, // px/frame cap — prevents jarring lurch on large jumps
|
||||
) {
|
||||
this.container = container;
|
||||
this.target = container.scrollTop;
|
||||
this.stiffness = stiffness;
|
||||
this.damping = damping;
|
||||
this.maxVelocity = maxVelocity;
|
||||
}
|
||||
|
||||
scrollTo(targetY: number) {
|
||||
this.target = Math.max(0, targetY);
|
||||
// Software-rendered Linux: replace our 60 fps rAF spring with the
|
||||
// browser's native smooth scroll. The browser handles easing internally
|
||||
// (one animation, not 60 JS callbacks per second) and we still get a
|
||||
// smooth visual instead of a hard snap.
|
||||
if (document.documentElement.classList.contains('no-compositing')) {
|
||||
this.stop();
|
||||
this.container.scrollTo({ top: this.target, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
if (this.rafId === null) this.tick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.velocity = 0;
|
||||
}
|
||||
|
||||
/** Teleport without animation (e.g. on track reset). */
|
||||
jump(y: number) {
|
||||
this.stop();
|
||||
this.target = y;
|
||||
this.container.scrollTop = y;
|
||||
}
|
||||
|
||||
private tick = () => {
|
||||
const pos = this.container.scrollTop;
|
||||
const delta = this.target - pos;
|
||||
|
||||
let v = (this.velocity + delta * this.stiffness) * this.damping;
|
||||
// Cap velocity so large distances don't start with a hard jerk.
|
||||
if (v > this.maxVelocity) v = this.maxVelocity;
|
||||
if (v < -this.maxVelocity) v = -this.maxVelocity;
|
||||
this.velocity = v;
|
||||
|
||||
this.container.scrollTop += v;
|
||||
|
||||
const settled = Math.abs(v) < 0.12 && Math.abs(delta) < 0.5;
|
||||
if (settled) {
|
||||
this.container.scrollTop = this.target;
|
||||
this.rafId = null;
|
||||
this.velocity = 0;
|
||||
} else {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: compute the scroll position that places `el` at `fraction`
|
||||
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style).
|
||||
*/
|
||||
export function targetForFraction(
|
||||
container: HTMLElement,
|
||||
el : HTMLElement,
|
||||
fraction = 0.35,
|
||||
): number {
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const eRect = el.getBoundingClientRect();
|
||||
return container.scrollTop + (eRect.top - cRect.top) - cRect.height * fraction + eRect.height / 2;
|
||||
}
|
||||
Reference in New Issue
Block a user