mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-26 17:17:40 +00:00
Compare commits
49 Commits
v1.33.0
...
app-v1.34.3
| Author | SHA1 | Date | |
|---|---|---|---|
| dd4e2f1162 | |||
| 78beb699a7 | |||
| 6f63d7020c | |||
| a606d1edd6 | |||
| 9ef8566d64 | |||
| 4b8b6ae797 | |||
| 9319c40fde | |||
| 63a3bcd0f4 | |||
| a4c400cc69 | |||
| e4aad22c03 | |||
| 489d50016f | |||
| 47490072ef | |||
| ff0a588014 | |||
| e4041287e2 | |||
| 70f0641439 | |||
| c779e8f587 | |||
| 95468fb137 | |||
| c36b1ea538 | |||
| 3f1b6fd92d | |||
| 1c2aa79e29 | |||
| f08619fb3d | |||
| 5ec8fa8ba3 | |||
| 51c118806e | |||
| fc653941c2 | |||
| 8add62a502 | |||
| 44287a7ceb | |||
| 0f3033d84e | |||
| d49af977ed | |||
| d73f348339 | |||
| 390e6e788d | |||
| ff950efb0c | |||
| 4ef21d6d78 | |||
| 7fc0550b65 | |||
| 5d06738ce1 | |||
| 1197c1f916 | |||
| b448c2bc82 | |||
| 6226383762 | |||
| 13a8f696d0 | |||
| 64e0948904 | |||
| 10b2bde5ef | |||
| 1c92f9ed74 | |||
| 443e0c0675 | |||
| 9cc8cfe087 | |||
| 7ac417fbfc | |||
| 50ac1b8284 | |||
| 29203803de | |||
| 83c36de091 | |||
| 1d0965708b | |||
| 2b5416f423 |
@@ -80,9 +80,6 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -123,43 +120,8 @@ jobs:
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: app-v${{ needs.create-release.outputs.package_version }}
|
||||
releaseName: Psysonic v${{ needs.create-release.outputs.package_version }}
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: sign and upload Windows NSIS updater bundle
|
||||
if: matrix.settings.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
$dir = "src-tauri/target/release/bundle/nsis"
|
||||
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
|
||||
$zip = $exe -replace '\.exe$', '.nsis.zip'
|
||||
Compress-Archive -Path $exe -DestinationPath $zip -Force
|
||||
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
|
||||
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
|
||||
|
||||
- name: sign and upload macOS bundle signature
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
|
||||
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
|
||||
else
|
||||
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
|
||||
fi
|
||||
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
|
||||
cp "${BUNDLE}.sig" "$SIG_NAME"
|
||||
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
@@ -212,32 +174,3 @@ jobs:
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
+129
@@ -5,6 +5,135 @@ 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).
|
||||
|
||||
## [1.34.3] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Most Played page** *(closes [#86](https://github.com/Psychotoxical/psysonic/issues/86))*: New dedicated page accessible via the sidebar (TrendingUp icon, `/most-played`). Shows **Top Artists** (ranked by total play count, derived by aggregating album play counts per artist) and a paginated **Top Albums** list with cover art, play count, sort toggle (most/fewest first), and a Load More button.
|
||||
|
||||
- **Playlist ZIP download** *(closes [#127](https://github.com/Psychotoxical/psysonic/issues/127))*: Download (ZIP) button in the playlist hero header — same UX as album download. Uses the Subsonic `/rest/download.view` endpoint with the playlist ID, shows a progress bar during transfer, and remembers the last used folder.
|
||||
|
||||
- **Fullscreen Player — adaptive accent color**: Extracts the most vibrant pixel from the current album cover (8×8 Canvas downscale, max-HSL-saturation) and applies a WCAG 4.5:1-compliant accent as `--dynamic-fs-accent`. Song title, play button, seekbar, active states, background mesh blobs, and cover art glow all transition smoothly to the extracted color. Resets to the theme accent when the player closes.
|
||||
|
||||
- **Dracula theme**: Added to the Open Source Classics group.
|
||||
|
||||
- **Discord Rich Presence — Apple Music cover opt-in**: iTunes artwork lookup is now disabled by default. A new toggle in Settings → Integrations ("Fetch covers from Apple Music for Discord") must be explicitly enabled — it sends artist and album name to Apple's search API to find cover art for the Discord profile.
|
||||
|
||||
- **Discord Rich Presence — Paused state**: When playback is paused, the Discord presence now shows "Paused" as the status text.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **M4A playback — older iTunes-purchased files**: Files with an embedded MJPEG cover-art stream and an `iTunSMPB` gapless tag now play correctly. The Symphonia isomp4 patch skips malformed trak atoms gracefully; `parse_gapless_info` now searches for the `" 00000000 "` sentinel to skip the 16-byte binary `data`-atom header, correctly extracting encoder delay and total sample count.
|
||||
|
||||
### i18n
|
||||
|
||||
- New keys for the Most Played page, playlist download, and Discord Apple Music opt-in added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||
|
||||
---
|
||||
|
||||
## [1.34.2] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- **M4A / ALAC / AAC-LC support** *(closes [#51](https://github.com/Psychotoxical/psysonic/issues/51))*: Apple Lossless (ALAC) and AAC-LC files in M4A containers are now decoded natively by the Rust audio engine (Symphonia) without requiring server-side transcoding.
|
||||
|
||||
- **Per-server music folder filter** *(PR [#125](https://github.com/Psychotoxical/psysonic/pull/125) by [@cucadmuh](https://github.com/cucadmuh))*: Users with multiple music libraries on their Navidrome server can now scope browsing to a single folder. A dropdown in the sidebar (visible only when more than one library exists) lets you pick a folder or switch back to "All Libraries". The selection is persisted per server and automatically resets to "All" if the selected folder is no longer available.
|
||||
|
||||
- **Hi-Res / Bit-Perfect Playback** *(Alpha)*: New opt-in toggle in Settings → Playback. When enabled, the audio output stream is re-opened at the file's native sample rate (e.g. 88.2 kHz, 96 kHz) — bypassing rodio's internal resampler for a bit-perfect signal path. Disabled by default (safe 44.1 kHz mode). Includes ALSA/PipeWire underrun hardening: scaled quantum size, 500 ms sink pre-fill at high rates, and scheduler priority escalation only when needed.
|
||||
|
||||
- **Hot Playback Cache** *(Alpha, PR [#123](https://github.com/Psychotoxical/psysonic/pull/123) by [@cucadmuh](https://github.com/cucadmuh))*: Configurable on-disk prefetch cache for the next track in the queue. Reduces playback latency on slow or metered connections. Toggle and directory can be configured in Settings → Storage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fullscreen Player — info block reworked**: The track title is now the dominant element (large, bold, accent color) and sits above the artist name (small, muted). Matches community feedback on visual hierarchy.
|
||||
|
||||
- **Fullscreen lyrics — line wrapping**: Long lyric lines now wrap onto a second line instead of being truncated. Slot height increased from 3.6 vh to 6 vh to accommodate two-line entries without breaking rail positioning.
|
||||
|
||||
- **Update notifications**: Removed the Tauri auto-updater (in-app download and install). The app now shows a simple dismissible toast when a newer version is detected on GitHub, with direct links to the [GitHub Releases page](https://github.com/Psychotoxical/psysonic/releases/latest) and the [Psysonic website](https://psysonic.psychotoxic.eu/#downloads). No signing keys, no update manifests.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Standard mode CPU usage**: Playing a 44.1 kHz MP3 with Hi-Res disabled no longer triggers an unnecessary audio device re-open on every track start. MSS read-ahead buffer reduced from 4 MB to 512 KB for standard-rate files. Background prefetch is now throttled by 8 s to avoid competing with playback startup. Combined, these changes reduce idle CPU from ~6–10 % to ~2–3 % on a modern machine.
|
||||
|
||||
- **Hi-Res toggle — stream rate not restored**: Toggling Hi-Res off while a track was playing at 88.2 or 96 kHz left the output stream at the high rate for subsequent tracks. The device's default rate is now restored on the next play.
|
||||
|
||||
- **Fullscreen lyrics — CPU spikes on line transitions**: Animating `font-weight` in CSS triggered a full layout reflow on every animation frame. Removed `font-weight` from the transition list; active-line emphasis now uses `transform: scaleX(1.015)` (compositor-only). Added `contain: layout style` to the overlay to isolate reflows from the rest of the page.
|
||||
|
||||
### i18n
|
||||
|
||||
- New keys for Hi-Res playback settings and music folder filter added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||
|
||||
---
|
||||
|
||||
## [1.34.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Fullscreen Player — Synced Lyrics Overlay**: Synced lyrics are now displayed directly in the Fullscreen Player as an animated 5-line rail with a soft fade mask at the top and bottom edges. Click any visible line to seek to that position. Toggle the overlay on/off with the new microphone icon button next to the heart — preference is persisted.
|
||||
|
||||
> **Note:** The overlay currently requires synced (timestamped) lyrics. Support for unsynced lyrics in the Fullscreen Player is planned for a future release.
|
||||
|
||||
- **Embedded Lyrics & LRC support**: The app now fetches lyrics from two sources using the shared `useLyrics` hook (used by both the Lyrics Pane and the Fullscreen overlay):
|
||||
- **Server-embedded lyrics** via the OpenSubsonic `getLyricsBySongId` endpoint — reads timestamped or plain lyrics baked directly into the audio file's tags (Navidrome 0.53+).
|
||||
- **LRCLIB** — external LRC lookup as fallback (or primary, configurable in Settings → Playback).
|
||||
Both sources share a module-level cache so switching between the Lyrics Pane and the Fullscreen Player never triggers a second network request.
|
||||
|
||||
- **Artist Image Upload**: A camera overlay now appears when hovering the artist portrait on the Artist page. Clicking it opens a file picker and uploads the image directly to your server.
|
||||
|
||||
> **Requires `EnableArtworkUpload = true`** in your Navidrome configuration (new option in Navidrome [#5110](https://github.com/navidrome/navidrome/issues/5110) / [#5198](https://github.com/navidrome/navidrome/issues/5198) — default: `true`). The same requirement applies to the existing Radio Station cover upload.
|
||||
|
||||
- **Discord Rich Presence — Album Cover Art**: Album artwork is now displayed in Discord's Rich Presence card. Because Subsonic cover URLs require authentication (and can't be accessed by Discord directly), artwork is fetched from the iTunes Search API using a 3-strategy search (exact → relaxed → track-title fallback), cached for 1 hour, and passed as a direct URL to Discord. Falls back to the static Psysonic asset when no match is found.
|
||||
- **Nightfox themes** *(PR [#112](https://github.com/Psychotoxical/psysonic/pull/112) by [@nisrael](https://github.com/nisrael))*: Six themes from the [nightfox.nvim](https://github.com/EdenEast/nightfox.nvim) palette have been added to the **Open Source Classics** group — Dawnfox, Dayfox, Nightfox, Nordfox, Carbonfox, and Terafox.
|
||||
- **Auto-install script** *(PR [#121](https://github.com/Psychotoxical/psysonic/pull/121) by [@kilyabin](https://github.com/kilyabin))*: `install.sh` now supports Debian/Ubuntu (`.deb`) and RHEL/Fedora (`.rpm`) — automatically detects the distro, downloads the correct package from the latest release, and installs it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fullscreen Player — performance overhaul**:
|
||||
- `FsArt` (cover art) and `FsLyrics` are now isolated `memo` components — unrelated state changes no longer trigger their re-renders.
|
||||
- Cover crossfade uses an `onLoad` DOM event instead of `new Image()` preloading. This avoids a React batching edge case where both state updates were flushed together and the browser never saw the `opacity: 0` starting state, preventing the CSS transition from firing.
|
||||
- `useCachedUrl(..., true)` passes the raw URL as an immediate fallback — the image starts fetching from the network instantly while IndexedDB resolves the blob in the background.
|
||||
- Lyrics slot height is stored in a `useRef` and updated only on `resize` — eliminates repeated `window.innerHeight` layout reads on every render.
|
||||
- Mouse-move handler is throttled to 200 ms.
|
||||
- **Artist page — biography**: The bio text is now collapsed by default with a *Read more* / *Show less* toggle button, keeping the page layout clean for artists with long bios.
|
||||
- **Settings — Logout button**: Moved from the System tab to the bottom of the Server tab, styled as a danger button (red outline → red fill on hover).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gapless playback — manual skip** *(PR [#119](https://github.com/Psychotoxical/psysonic/pull/119) by [@cucadmuh](https://github.com/cucadmuh))*: When the next track had already been gapless-pre-chained into the Sink, a manual skip would not interrupt it — the pre-chained track continued playing at full volume from the old Sink after the fade-out. The chain is now matched by stream identity so user-initiated playback always takes precedence.
|
||||
- **Radio / Artist cover cache**: `invalidateCoverArt` is now called after every cover upload and delete, so the old image is evicted from the local cache immediately.
|
||||
- **Queue auto-scroll**: The active track now scrolls reliably into view; eliminated unnecessary component re-renders caused by unstable selector references.
|
||||
- **macOS TLS** *(PR [#114](https://github.com/Psychotoxical/psysonic/pull/114) by [@nisrael](https://github.com/nisrael))*: Switched `reqwest` from `native-tls` (macOS Security framework) to `rustls-tls` (statically linked). The native backend was returning *bad protocol version* when connecting to HTTPS music servers, silently preventing playback.
|
||||
|
||||
### i18n
|
||||
|
||||
- **Russian translation improvements** *(PR [#120](https://github.com/Psychotoxical/psysonic/pull/120) by [@kilyabin](https://github.com/kilyabin))*: Extensive phrasing refinements across the entire Russian locale.
|
||||
- New keys (`fsLyricsToggle`, embedded lyrics settings) added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||
|
||||
---
|
||||
|
||||
## [1.34.0] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Mobile UI — Early Preview** ⚠️ — After multiple requests from the community, an initial mobile layout is shipping in this release. **This is a very early work-in-progress** — expect rough edges, missing features, and layouts that still need a lot of polish. Feedback is very welcome! Join the [Discord](https://discord.gg/ckVPGPMS) to share your thoughts.
|
||||
- Sidebar and queue panel are hidden on mobile; a sticky **Bottom Navigation Bar** replaces them with quick access to Mainstage, Albums, Now Playing, and Search.
|
||||
- **Mobile Player View** (`/now-playing`) — Full-screen ambient player with dynamic album-art-based background color, large cover art, track metadata line, and playback controls.
|
||||
- **Mobile Search Overlay** — Full-screen search with recent search history, category chips (Albums, Artists, Genres), and grouped results.
|
||||
- **Mobile Album Header** — Compact two-row icon button layout (Play + Queue primary, Favorite + Bio + Download + Offline secondary).
|
||||
- **Mobile Tracklist** — Simplified track rows; disc headers preserved for multi-disc albums.
|
||||
- **Mobile Hero / Carousel** — Blurred-background-only layout with circular Play + Queue buttons.
|
||||
- **Russian 2 translation** *(PR [#107](https://github.com/Psychotoxical/psysonic/pull/107) by [@kilyabin](https://github.com/kilyabin))*: A second Russian translation alongside the existing one from [@cucadmuh](https://github.com/cucadmuh) *(PR [#106](https://github.com/Psychotoxical/psysonic/pull/106))*. Both are selectable in Settings → Appearance as **Russian** and **Russian 2**. Since the maintainer neither speaks nor reads Russian, **community feedback is essential here** — please vote on the [Discord](https://discord.gg/ckVPGPMS) or via GitHub which translation feels more natural so we can retire the weaker one in a future release.
|
||||
- **Clickable Mainstage section headers** — "Zuletzt hinzugefügt", "Entdecken", "Künstler entdecken", and "Persönliche Favoriten" now navigate to their respective pages on click, with a `ChevronRight` indicator and accent-color hover effect.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS network playback** *(Issue [#108](https://github.com/Psychotoxical/psysonic/issues/108))*: Added `com.apple.security.network.client` to `Entitlements.plist` and disabled the app sandbox for unsigned/ad-hoc builds. Without this, macOS silently blocked outbound TCP connections from the Rust audio engine, causing the player to skip through every track without playing anything.
|
||||
- **Auto-updater** *(under observation)*: Fixed an incorrect signature in the auto-generated `latest.json` — the CI was writing the public key as the signature value. The updater now receives a correctly signed manifest. **Note:** Due to OS-level restrictions on macOS (Gatekeeper) and Windows (SmartScreen) for unsigned apps, it is not yet certain whether the in-app updater will reliably work on these platforms. Manual installation from the Releases page remains the safe fallback.
|
||||
|
||||
### Changed
|
||||
|
||||
- All new i18n keys added to all 8 languages (EN, DE, FR, NL, ZH, NB, RU, RU2).
|
||||
|
||||
## [1.33.0] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
|
||||
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
<p>
|
||||
@@ -8,11 +8,21 @@
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/ckVPGPMS">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
|
||||
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
@@ -22,7 +32,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
@@ -82,6 +92,12 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
|
||||
|
||||
### 🐧 Linux
|
||||
|
||||
**Quick Install (Recommended):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
**Manual Installation:**
|
||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
||||
|
||||
|
||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.30.0",
|
||||
"version": "1.34.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.30.0",
|
||||
"version": "1.34.2",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -36,7 +36,8 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1227,6 +1228,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
@@ -1613,6 +1621,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1686,6 +1712,129 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
|
||||
"integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.3",
|
||||
"@vitest/utils": "4.1.3",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
|
||||
"integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.3",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
|
||||
"integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
|
||||
"integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.3",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
|
||||
"integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.3",
|
||||
"@vitest/utils": "4.1.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
|
||||
"integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
|
||||
"integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.3",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -1784,6 +1933,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
@@ -1894,6 +2053,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -1973,6 +2139,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2258,6 +2444,16 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2332,6 +2528,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2545,6 +2759,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2555,6 +2776,37 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
||||
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2572,6 +2824,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2708,6 +2970,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
|
||||
"integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.3",
|
||||
"@vitest/mocker": "4.1.3",
|
||||
"@vitest/pretty-format": "4.1.3",
|
||||
"@vitest/runner": "4.1.3",
|
||||
"@vitest/snapshot": "4.1.3",
|
||||
"@vitest/spy": "4.1.3",
|
||||
"@vitest/utils": "4.1.3",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.3",
|
||||
"@vitest/browser-preview": "4.1.3",
|
||||
"@vitest/browser-webdriverio": "4.1.3",
|
||||
"@vitest/coverage-istanbul": "4.1.3",
|
||||
"@vitest/coverage-v8": "4.1.3",
|
||||
"@vitest/ui": "4.1.3",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
@@ -2717,6 +3069,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -8,7 +8,8 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.33.0
|
||||
pkgver=1.34.3
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -28,7 +28,7 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
|
||||
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Scripts
|
||||
|
||||
## install.sh - Auto-Installer for Debian and RHEL-based Systems
|
||||
|
||||
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
|
||||
|
||||
### Supported Distributions
|
||||
|
||||
- **Debian/Ubuntu**: Downloads and installs `.deb` package
|
||||
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
|
||||
|
||||
### Usage
|
||||
|
||||
#### Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
|
||||
|
||||
# Make it executable
|
||||
chmod +x install.sh
|
||||
|
||||
# Run with sudo
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
1. Detects your OS type (Debian or RHEL-based)
|
||||
2. Fetches the latest release from GitHub
|
||||
3. Downloads the appropriate package (.deb or .rpm)
|
||||
4. Installs it using your system's package manager
|
||||
5. Cleans up temporary files
|
||||
|
||||
### Requirements
|
||||
|
||||
- `curl` - for downloading packages
|
||||
- `sudo` or root access
|
||||
- Internet connection
|
||||
- Supported package manager (apt-get, dnf, or yum)
|
||||
|
||||
### Notes
|
||||
|
||||
- If Psysonic is already installed, the script will ask if you want to reinstall
|
||||
- The script automatically handles dependency installation for Debian systems
|
||||
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
|
||||
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
|
||||
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pull release notes from GitHub
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Psysonic Auto-Installer
|
||||
# Automatically detects your OS and installs the latest release from GitHub
|
||||
|
||||
REPO="Psychotoxical/psysonic"
|
||||
APP_NAME="psysonic"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Please run this script as root (use sudo)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect package manager and OS type
|
||||
detect_os() {
|
||||
if command -v apt &> /dev/null; then
|
||||
OS_TYPE="debian"
|
||||
PACKAGE_MANAGER="apt"
|
||||
info "Detected Debian/Ubuntu-based system (apt)"
|
||||
elif command -v dnf &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="dnf"
|
||||
info "Detected RHEL/Fedora-based system (dnf)"
|
||||
elif command -v yum &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="yum"
|
||||
info "Detected RHEL/CentOS-based system (yum)"
|
||||
else
|
||||
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the latest release download URL for the specific package type
|
||||
get_download_url() {
|
||||
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
info "Fetching latest release information..."
|
||||
|
||||
local release_info
|
||||
release_info=$(curl -s "$api_url")
|
||||
|
||||
if echo "$release_info" | grep -q "message.*Not Found"; then
|
||||
error "Could not fetch release information. Please check your internet connection."
|
||||
fi
|
||||
|
||||
local tag_name
|
||||
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$tag_name" ]; then
|
||||
error "Could not determine latest release version."
|
||||
fi
|
||||
|
||||
info "Latest version: $tag_name"
|
||||
|
||||
local download_url=""
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Could not find download URL for $OS_TYPE package."
|
||||
fi
|
||||
|
||||
echo "$download_url"
|
||||
}
|
||||
|
||||
# Install the package
|
||||
install_package() {
|
||||
local download_url="$1"
|
||||
local temp_dir
|
||||
temp_dir=$(mktemp -d)
|
||||
local package_file="$temp_dir/${APP_NAME}_latest"
|
||||
|
||||
info "Downloading package..."
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
warn "Trying to fix broken dependencies..."
|
||||
$PACKAGE_MANAGER install -f -y
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
# Check if app is already installed
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo -e "${GREEN}"
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ Psysonic Auto-Installer ║"
|
||||
echo "║ Install the latest release from GitHub Releases ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
check_root
|
||||
detect_os
|
||||
check_installed
|
||||
|
||||
local download_url
|
||||
download_url=$(get_download_url)
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Failed to get download URL."
|
||||
fi
|
||||
|
||||
info "Download URL: $download_url"
|
||||
|
||||
install_package "$download_url"
|
||||
|
||||
echo ""
|
||||
success "Psysonic has been installed successfully!"
|
||||
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
Generated
+165
-385
@@ -69,15 +69,6 @@ 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"
|
||||
@@ -584,6 +575,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -931,17 +928,6 @@ 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"
|
||||
@@ -1254,17 +1240,6 @@ 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"
|
||||
@@ -1361,6 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1599,8 +1575,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1610,9 +1588,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1794,25 +1774,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1925,7 +1886,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1951,22 +1911,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1987,11 +1932,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2441,10 +2384,7 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2486,6 +2426,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2593,12 +2539,6 @@ 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"
|
||||
@@ -2651,23 +2591,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
@@ -2882,7 +2805,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2898,18 +2820,6 @@ 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.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2989,50 +2899,6 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3059,20 +2925,6 @@ 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"
|
||||
@@ -3122,7 +2974,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3302,12 +3154,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[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"
|
||||
@@ -3493,7 +3339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.33.0"
|
||||
version = "1.34.3"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -3515,9 +3361,10 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3535,6 +3382,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3581,6 +3483,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3601,6 +3513,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3619,6 +3541,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3652,15 +3583,6 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3729,31 +3651,29 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3763,6 +3683,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3779,20 +3700,15 @@ 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",
|
||||
@@ -3931,54 +3847,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"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.9"
|
||||
@@ -4011,15 +3889,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -4077,29 +3946,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4433,7 +4279,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4556,6 +4402,7 @@ dependencies = [
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-alac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
@@ -4610,6 +4457,16 @@ dependencies = [
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-alac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
@@ -4647,8 +4504,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -4745,27 +4600,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4828,17 +4662,6 @@ 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"
|
||||
@@ -5094,39 +4917,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
||||
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"
|
||||
@@ -5306,6 +5096,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread-priority"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.45"
|
||||
@@ -5347,6 +5151,21 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
@@ -5373,16 +5192,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5767,12 +5576,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -5989,6 +5792,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
@@ -6034,10 +5847,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -6256,17 +6069,6 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -6832,16 +6634,6 @@ 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"
|
||||
@@ -7088,18 +6880,6 @@ 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.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
+13
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.33.0"
|
||||
version = "1.34.3"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -31,15 +31,24 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
|
||||
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
|
||||
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
|
||||
# of failing the entire probe
|
||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
|
||||
would restrict network access more than without it. macOS users bypass
|
||||
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
|
||||
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
|
||||
Respected by ad-hoc signatures on some macOS versions. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
|
||||
<!-- Psysonic is a music PLAYER only — no microphone access needed.
|
||||
This suppresses the macOS microphone permission prompt triggered
|
||||
by cpal/CoreAudio enumerating input devices during audio init. -->
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6284,60 +6284,6 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -6284,60 +6284,6 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
|
||||
},
|
||||
"path_in_vcs": "symphonia-format-isomp4"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2018"
|
||||
rust-version = "1.53"
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
|
||||
homepage = "https://github.com/pdeljanov/Symphonia"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"audio",
|
||||
"media",
|
||||
"demuxer",
|
||||
"mp4",
|
||||
"iso",
|
||||
]
|
||||
categories = [
|
||||
"multimedia",
|
||||
"multimedia::audio",
|
||||
"multimedia::encoding",
|
||||
]
|
||||
license = "MPL-2.0"
|
||||
repository = "https://github.com/pdeljanov/Symphonia"
|
||||
|
||||
[lib]
|
||||
name = "symphonia_format_isomp4"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.encoding_rs]
|
||||
version = "0.8.17"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.symphonia-core]
|
||||
version = "0.5.5"
|
||||
|
||||
[dependencies.symphonia-metadata]
|
||||
version = "0.5.5"
|
||||
|
||||
[dependencies.symphonia-utils-xiph]
|
||||
version = "0.5.5"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
|
||||
homepage = "https://github.com/pdeljanov/Symphonia"
|
||||
repository = "https://github.com/pdeljanov/Symphonia"
|
||||
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
|
||||
license = "MPL-2.0"
|
||||
readme = "README.md"
|
||||
categories = ["multimedia", "multimedia::audio", "multimedia::encoding"]
|
||||
keywords = ["audio", "media", "demuxer", "mp4", "iso"]
|
||||
edition = "2018"
|
||||
rust-version = "1.53"
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8.17"
|
||||
log = "0.4"
|
||||
symphonia-core = { version = "0.5.5", path = "../symphonia-core" }
|
||||
symphonia-metadata = { version = "0.5.5", path = "../symphonia-metadata" }
|
||||
symphonia-utils-xiph = { version = "0.5.5", path = "../symphonia-utils-xiph" }
|
||||
@@ -0,0 +1,15 @@
|
||||
# Symphonia ISO/MP4 Demuxer
|
||||
|
||||
[](https://docs.rs/symphonia-format-isomp4)
|
||||
|
||||
ISO/MP4 demuxer for Project Symphonia.
|
||||
|
||||
**Note:** This crate is part of Project Symphonia. Please use the [`symphonia`](https://crates.io/crates/symphonia) crate instead of this one directly.
|
||||
|
||||
## License
|
||||
|
||||
Symphonia is provided under the MPL v2.0 license. Please refer to the LICENSE file for more details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Symphonia is a free and open-source project that welcomes contributions! To get started, please read our [Contribution Guidelines](https://github.com/pdeljanov/Symphonia/tree/master/CONTRIBUTING.md).
|
||||
@@ -0,0 +1,60 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_ALAC};
|
||||
use symphonia_core::errors::{decode_error, unsupported_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct AlacAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// ALAC extra data (magic cookie).
|
||||
extra_data: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl Atom for AlacAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
if version != 0 {
|
||||
return unsupported_error("isomp4 (alac): unsupported alac version");
|
||||
}
|
||||
|
||||
if flags != 0 {
|
||||
return decode_error("isomp4 (alac): flags not zero");
|
||||
}
|
||||
|
||||
if header.data_len <= AtomHeader::EXTRA_DATA_SIZE {
|
||||
return decode_error("isomp4 (alac): invalid alac atom length");
|
||||
}
|
||||
|
||||
// The ALAC magic cookie (aka extra data) is either 24 or 48 bytes long.
|
||||
let magic_len = match header.data_len - AtomHeader::EXTRA_DATA_SIZE {
|
||||
len @ 24 | len @ 48 => len as usize,
|
||||
_ => return decode_error("isomp4 (alac): invalid magic cookie length"),
|
||||
};
|
||||
|
||||
// Read the magic cookie.
|
||||
let extra_data = reader.read_boxed_slice_exact(magic_len)?;
|
||||
|
||||
Ok(AlacAtom { header, extra_data })
|
||||
}
|
||||
}
|
||||
|
||||
impl AlacAtom {
|
||||
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
|
||||
codec_params.for_codec(CODEC_TYPE_ALAC).with_extra_data(self.extra_data.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Chunk offset atom (64-bit version).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct Co64Atom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub chunk_offsets: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Atom for Co64Atom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let entry_count = reader.read_be_u32()?;
|
||||
|
||||
// TODO: Apply a limit.
|
||||
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
|
||||
|
||||
for _ in 0..entry_count {
|
||||
chunk_offsets.push(reader.read_be_u64()?);
|
||||
}
|
||||
|
||||
Ok(Co64Atom { header, chunk_offsets })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Composition time atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct CttsAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
}
|
||||
|
||||
impl Atom for CttsAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, ElstAtom};
|
||||
|
||||
/// Edits atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct EdtsAtom {
|
||||
header: AtomHeader,
|
||||
pub elst: Option<ElstAtom>,
|
||||
}
|
||||
|
||||
impl Atom for EdtsAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
#[allow(clippy::single_match)]
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut elst = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::EditList => {
|
||||
elst = Some(iter.read_atom::<ElstAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EdtsAtom { header, elst })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
use symphonia_core::util::bits;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Edit list entry.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ElstEntry {
|
||||
segment_duration: u64,
|
||||
media_time: i64,
|
||||
media_rate_int: i16,
|
||||
media_rate_frac: i16,
|
||||
}
|
||||
|
||||
/// Edit list atom.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ElstAtom {
|
||||
header: AtomHeader,
|
||||
entries: Vec<ElstEntry>,
|
||||
}
|
||||
|
||||
impl Atom for ElstAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
// TODO: Apply a limit.
|
||||
let entry_count = reader.read_be_u32()?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for _ in 0..entry_count {
|
||||
let (segment_duration, media_time) = match version {
|
||||
0 => (
|
||||
u64::from(reader.read_be_u32()?),
|
||||
i64::from(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
|
||||
),
|
||||
1 => (
|
||||
reader.read_be_u64()?,
|
||||
bits::sign_extend_leq64_to_i64(reader.read_be_u64()?, 64),
|
||||
),
|
||||
_ => return decode_error("isomp4: invalid tkhd version"),
|
||||
};
|
||||
|
||||
let media_rate_int = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
|
||||
let media_rate_frac = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
|
||||
|
||||
entries.push(ElstEntry {
|
||||
segment_duration,
|
||||
media_time,
|
||||
media_rate_int,
|
||||
media_rate_frac,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ElstAtom { header, entries })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::codecs::{
|
||||
CodecParameters, CodecType, CODEC_TYPE_AAC, CODEC_TYPE_MP3, CODEC_TYPE_NULL,
|
||||
};
|
||||
use symphonia_core::errors::{decode_error, unsupported_error, Result};
|
||||
use symphonia_core::io::{FiniteStream, ReadBytes, ScopedStream};
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
use log::{debug, warn};
|
||||
|
||||
const ES_DESCRIPTOR: u8 = 0x03;
|
||||
const DECODER_CONFIG_DESCRIPTOR: u8 = 0x04;
|
||||
const DECODER_SPECIFIC_DESCRIPTOR: u8 = 0x05;
|
||||
const SL_CONFIG_DESCRIPTOR: u8 = 0x06;
|
||||
|
||||
const MIN_DESCRIPTOR_SIZE: u64 = 2;
|
||||
|
||||
fn read_descriptor_header<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
|
||||
let tag = reader.read_u8()?;
|
||||
|
||||
let mut size = 0;
|
||||
|
||||
for _ in 0..4 {
|
||||
let val = reader.read_u8()?;
|
||||
size = (size << 7) | u32::from(val & 0x7f);
|
||||
if val & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((tag, size))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct EsdsAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Elementary stream descriptor.
|
||||
descriptor: ESDescriptor,
|
||||
}
|
||||
|
||||
impl Atom for EsdsAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let mut descriptor = None;
|
||||
|
||||
let mut scoped = ScopedStream::new(reader, header.data_len - AtomHeader::EXTRA_DATA_SIZE);
|
||||
|
||||
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
|
||||
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
|
||||
|
||||
match desc {
|
||||
ES_DESCRIPTOR => {
|
||||
descriptor = Some(ESDescriptor::read(&mut scoped, desc_len)?);
|
||||
}
|
||||
_ => {
|
||||
warn!("unknown descriptor in esds atom, desc={}", desc);
|
||||
scoped.ignore_bytes(desc_len as u64)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore remainder of the atom.
|
||||
scoped.ignore()?;
|
||||
|
||||
// Guard against malformed esds atoms that contain no ES_DESCRIPTOR (old iTunes files).
|
||||
let descriptor = descriptor
|
||||
.ok_or_else(|| symphonia_core::errors::Error::DecodeError("isomp4: missing es descriptor in esds atom"))?;
|
||||
|
||||
Ok(EsdsAtom { header, descriptor })
|
||||
}
|
||||
}
|
||||
|
||||
impl EsdsAtom {
|
||||
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
|
||||
codec_params.for_codec(self.descriptor.dec_config.codec_type);
|
||||
|
||||
if let Some(ds_config) = &self.descriptor.dec_config.dec_specific_info {
|
||||
codec_params.with_extra_data(ds_config.extra_data.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ObjectDescriptor: Sized {
|
||||
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self>;
|
||||
}
|
||||
|
||||
/*
|
||||
class ES_Descriptor extends BaseDescriptor : bit(8) tag=ES_DescrTag {
|
||||
bit(16) ES_ID;
|
||||
bit(1) streamDependenceFlag;
|
||||
bit(1) URL_Flag;
|
||||
bit(1) OCRstreamFlag;
|
||||
bit(5) streamPriority;
|
||||
if (streamDependenceFlag)
|
||||
bit(16) dependsOn_ES_ID;
|
||||
if (URL_Flag) {
|
||||
bit(8) URLlength;
|
||||
bit(8) URLstring[URLlength];
|
||||
}
|
||||
if (OCRstreamFlag)
|
||||
bit(16) OCR_ES_Id;
|
||||
DecoderConfigDescriptor decConfigDescr;
|
||||
SLConfigDescriptor slConfigDescr;
|
||||
IPI_DescrPointer ipiPtr[0 .. 1];
|
||||
IP_IdentificationDataSet ipIDS[0 .. 255];
|
||||
IPMP_DescriptorPointer ipmpDescrPtr[0 .. 255];
|
||||
LanguageDescriptor langDescr[0 .. 255];
|
||||
QoS_Descriptor qosDescr[0 .. 1];
|
||||
RegistrationDescriptor regDescr[0 .. 1];
|
||||
ExtensionDescriptor extDescr[0 .. 255];
|
||||
}
|
||||
*/
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct ESDescriptor {
|
||||
pub es_id: u16,
|
||||
pub dec_config: DecoderConfigDescriptor,
|
||||
pub sl_config: SLDescriptor,
|
||||
}
|
||||
|
||||
impl ObjectDescriptor for ESDescriptor {
|
||||
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
|
||||
let es_id = reader.read_be_u16()?;
|
||||
let es_flags = reader.read_u8()?;
|
||||
|
||||
// Stream dependence flag.
|
||||
if es_flags & 0x80 != 0 {
|
||||
let _depends_on_es_id = reader.read_u16()?;
|
||||
}
|
||||
|
||||
// URL flag.
|
||||
if es_flags & 0x40 != 0 {
|
||||
let url_len = reader.read_u8()?;
|
||||
reader.ignore_bytes(u64::from(url_len))?;
|
||||
}
|
||||
|
||||
// OCR stream flag.
|
||||
if es_flags & 0x20 != 0 {
|
||||
let _ocr_es_id = reader.read_u16()?;
|
||||
}
|
||||
|
||||
let mut dec_config = None;
|
||||
let mut sl_config = None;
|
||||
|
||||
let mut scoped = ScopedStream::new(reader, u64::from(len) - 3);
|
||||
|
||||
// Multiple descriptors follow, but only the decoder configuration descriptor is useful.
|
||||
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
|
||||
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
|
||||
|
||||
match desc {
|
||||
DECODER_CONFIG_DESCRIPTOR => {
|
||||
dec_config = Some(DecoderConfigDescriptor::read(&mut scoped, desc_len)?);
|
||||
}
|
||||
SL_CONFIG_DESCRIPTOR => {
|
||||
sl_config = Some(SLDescriptor::read(&mut scoped, desc_len)?);
|
||||
}
|
||||
_ => {
|
||||
debug!("skipping {} object in es descriptor", desc);
|
||||
scoped.ignore_bytes(u64::from(desc_len))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consume remaining bytes.
|
||||
scoped.ignore()?;
|
||||
|
||||
// Decoder configuration descriptor is mandatory.
|
||||
if dec_config.is_none() {
|
||||
return decode_error("isomp4: missing decoder config descriptor");
|
||||
}
|
||||
|
||||
// SL descriptor is mandatory.
|
||||
if sl_config.is_none() {
|
||||
return decode_error("isomp4: missing sl config descriptor");
|
||||
}
|
||||
|
||||
Ok(ESDescriptor { es_id, dec_config: dec_config.unwrap(), sl_config: sl_config.unwrap() })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
class DecoderConfigDescriptor extends BaseDescriptor : bit(8) tag=DecoderConfigDescrTag {
|
||||
bit(8) objectTypeIndication;
|
||||
bit(6) streamType;
|
||||
bit(1) upStream;
|
||||
const bit(1) reserved=1;
|
||||
bit(24) bufferSizeDB;
|
||||
bit(32) maxBitrate;
|
||||
bit(32) avgBitrate;
|
||||
DecoderSpecificInfo decSpecificInfo[0 .. 1];
|
||||
profileLevelIndicationIndexDescriptor profileLevelIndicationIndexDescr [0..255];
|
||||
}
|
||||
*/
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct DecoderConfigDescriptor {
|
||||
pub codec_type: CodecType,
|
||||
pub object_type_indication: u8,
|
||||
pub dec_specific_info: Option<DecoderSpecificInfo>,
|
||||
}
|
||||
|
||||
impl ObjectDescriptor for DecoderConfigDescriptor {
|
||||
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
|
||||
// AAC
|
||||
const OBJECT_TYPE_ISO14496_3: u8 = 0x40;
|
||||
const OBJECT_TYPE_ISO13818_7_MAIN: u8 = 0x66;
|
||||
const OBJECT_TYPE_ISO13818_7_LC: u8 = 0x67;
|
||||
// MP3
|
||||
const OBJECT_TYPE_ISO13818_3: u8 = 0x69;
|
||||
const OBJECT_TYPE_ISO11172_3: u8 = 0x6b;
|
||||
|
||||
let object_type_indication = reader.read_u8()?;
|
||||
|
||||
let (_stream_type, _upstream) = {
|
||||
let val = reader.read_u8()?;
|
||||
|
||||
if val & 0x1 != 1 {
|
||||
debug!("decoder config descriptor reserved bit is not 1");
|
||||
}
|
||||
|
||||
((val & 0xfc) >> 2, (val & 0x2) >> 1)
|
||||
};
|
||||
|
||||
let _buffer_size = reader.read_be_u24()?;
|
||||
let _max_bitrate = reader.read_be_u32()?;
|
||||
let _avg_bitrate = reader.read_be_u32()?;
|
||||
|
||||
let mut dec_specific_config = None;
|
||||
|
||||
let mut scoped = ScopedStream::new(reader, u64::from(len) - 13);
|
||||
|
||||
// Multiple descriptors follow, but only the decoder specific info descriptor is useful.
|
||||
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
|
||||
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
|
||||
|
||||
match desc {
|
||||
DECODER_SPECIFIC_DESCRIPTOR => {
|
||||
dec_specific_config = Some(DecoderSpecificInfo::read(&mut scoped, desc_len)?);
|
||||
}
|
||||
_ => {
|
||||
debug!("skipping {} object in decoder config descriptor", desc);
|
||||
scoped.ignore_bytes(u64::from(desc_len))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let codec_type = match object_type_indication {
|
||||
OBJECT_TYPE_ISO14496_3 | OBJECT_TYPE_ISO13818_7_LC | OBJECT_TYPE_ISO13818_7_MAIN => {
|
||||
CODEC_TYPE_AAC
|
||||
}
|
||||
OBJECT_TYPE_ISO13818_3 | OBJECT_TYPE_ISO11172_3 => CODEC_TYPE_MP3,
|
||||
_ => {
|
||||
debug!(
|
||||
"unknown object type indication {:#x} for decoder config descriptor",
|
||||
object_type_indication
|
||||
);
|
||||
|
||||
CODEC_TYPE_NULL
|
||||
}
|
||||
};
|
||||
|
||||
// Consume remaining bytes.
|
||||
scoped.ignore()?;
|
||||
|
||||
Ok(DecoderConfigDescriptor {
|
||||
codec_type,
|
||||
object_type_indication,
|
||||
dec_specific_info: dec_specific_config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DecoderSpecificInfo {
|
||||
pub extra_data: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl ObjectDescriptor for DecoderSpecificInfo {
|
||||
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
|
||||
Ok(DecoderSpecificInfo { extra_data: reader.read_boxed_slice_exact(len as usize)? })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
class SLConfigDescriptor extends BaseDescriptor : bit(8) tag=SLConfigDescrTag {
|
||||
bit(8) predefined;
|
||||
if (predefined==0) {
|
||||
bit(1) useAccessUnitStartFlag;
|
||||
bit(1) useAccessUnitEndFlag;
|
||||
bit(1) useRandomAccessPointFlag;
|
||||
bit(1) hasRandomAccessUnitsOnlyFlag;
|
||||
bit(1) usePaddingFlag;
|
||||
bit(1) useTimeStampsFlag;
|
||||
bit(1) useIdleFlag;
|
||||
bit(1) durationFlag;
|
||||
bit(32) timeStampResolution;
|
||||
bit(32) OCRResolution;
|
||||
bit(8) timeStampLength; // must be 64
|
||||
bit(8) OCRLength; // must be 64
|
||||
bit(8) AU_Length; // must be 32
|
||||
bit(8) instantBitrateLength;
|
||||
bit(4) degradationPriorityLength;
|
||||
bit(5) AU_seqNumLength; // must be 16
|
||||
bit(5) packetSeqNumLength; // must be 16
|
||||
bit(2) reserved=0b11;
|
||||
}
|
||||
if (durationFlag) {
|
||||
bit(32) timeScale;
|
||||
bit(16) accessUnitDuration;
|
||||
bit(16) compositionUnitDuration;
|
||||
}
|
||||
if (!useTimeStampsFlag) {
|
||||
bit(timeStampLength) startDecodingTimeStamp;
|
||||
bit(timeStampLength) startCompositionTimeStamp;
|
||||
}
|
||||
}
|
||||
|
||||
timeStampLength == 32, for predefined == 0x1
|
||||
timeStampLength == 0, for predefined == 0x2
|
||||
*/
|
||||
#[derive(Debug)]
|
||||
pub struct SLDescriptor;
|
||||
|
||||
impl ObjectDescriptor for SLDescriptor {
|
||||
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
|
||||
// const SLCONFIG_PREDEFINED_CUSTOM: u8 = 0x0;
|
||||
const SLCONFIG_PREDEFINED_NULL: u8 = 0x1; // older iTunes M4A
|
||||
const SLCONFIG_PREDEFINED_MP4: u8 = 0x2;
|
||||
|
||||
let predefined = reader.read_u8()?;
|
||||
|
||||
match predefined {
|
||||
SLCONFIG_PREDEFINED_MP4 => {
|
||||
// Standard MP4: no extra fields. Nothing to read.
|
||||
}
|
||||
SLCONFIG_PREDEFINED_NULL => {
|
||||
// Older iTunes files use predefined=0x1. The SL descriptor in
|
||||
// this mode has no additional fields beyond the predefined byte,
|
||||
// so we just ignore the remaining bytes.
|
||||
if len > 1 {
|
||||
reader.ignore_bytes(u64::from(len - 1))?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return unsupported_error("isomp4: sl descriptor predefined not mp4 or null");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SLDescriptor {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::codecs::{CodecParameters, VerificationCheck, CODEC_TYPE_FLAC};
|
||||
use symphonia_core::errors::{decode_error, unsupported_error, Result};
|
||||
use symphonia_core::io::{BufReader, ReadBytes};
|
||||
|
||||
use symphonia_utils_xiph::flac::metadata::{MetadataBlockHeader, MetadataBlockType, StreamInfo};
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct FlacAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// FLAC stream info block.
|
||||
stream_info: StreamInfo,
|
||||
/// FLAC extra data.
|
||||
extra_data: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl Atom for FlacAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
if version != 0 {
|
||||
return unsupported_error("isomp4 (flac): unsupported flac version");
|
||||
}
|
||||
|
||||
if flags != 0 {
|
||||
return decode_error("isomp4 (flac): flags not zero");
|
||||
}
|
||||
|
||||
// The first block must be the stream information block.
|
||||
let block_header = MetadataBlockHeader::read(reader)?;
|
||||
|
||||
if block_header.block_type != MetadataBlockType::StreamInfo {
|
||||
return decode_error("isomp4 (flac): first block is not stream info");
|
||||
}
|
||||
|
||||
// Ensure the block length is correct for a stream information block before allocating a
|
||||
// buffer for it.
|
||||
if !StreamInfo::is_valid_size(u64::from(block_header.block_len)) {
|
||||
return decode_error("isomp4 (flac): invalid stream info block length");
|
||||
}
|
||||
|
||||
let extra_data = reader.read_boxed_slice_exact(block_header.block_len as usize)?;
|
||||
let stream_info = StreamInfo::read(&mut BufReader::new(&extra_data))?;
|
||||
|
||||
Ok(FlacAtom { header, stream_info, extra_data })
|
||||
}
|
||||
}
|
||||
|
||||
impl FlacAtom {
|
||||
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
|
||||
codec_params
|
||||
.for_codec(CODEC_TYPE_FLAC)
|
||||
.with_sample_rate(self.stream_info.sample_rate)
|
||||
.with_bits_per_sample(self.stream_info.bits_per_sample)
|
||||
.with_channels(self.stream_info.channels)
|
||||
.with_packet_data_integrity(true)
|
||||
.with_extra_data(self.extra_data.clone());
|
||||
|
||||
if let Some(md5) = self.stream_info.md5 {
|
||||
codec_params.with_verification_code(VerificationCheck::Md5(md5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
use crate::fourcc::FourCc;
|
||||
|
||||
/// File type atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct FtypAtom {
|
||||
header: AtomHeader,
|
||||
pub major: FourCc,
|
||||
pub minor: [u8; 4],
|
||||
pub compatible: Vec<FourCc>,
|
||||
}
|
||||
|
||||
impl Atom for FtypAtom {
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
// The Ftyp atom must be have a data length that is known, and it must be a multiple of 4
|
||||
// since it only stores FourCCs.
|
||||
if header.data_len < 8 || header.data_len & 0x3 != 0 {
|
||||
return decode_error("isomp4: invalid ftyp data length");
|
||||
}
|
||||
|
||||
// Major
|
||||
let major = FourCc::new(reader.read_quad_bytes()?);
|
||||
|
||||
// Minor
|
||||
let minor = reader.read_quad_bytes()?;
|
||||
|
||||
// The remainder of the Ftyp atom contains the FourCCs of compatible brands.
|
||||
let n_brands = (header.data_len - 8) / 4;
|
||||
|
||||
let mut compatible = Vec::new();
|
||||
|
||||
for _ in 0..n_brands {
|
||||
let brand = reader.read_quad_bytes()?;
|
||||
compatible.push(FourCc::new(brand));
|
||||
}
|
||||
|
||||
Ok(FtypAtom { header, major, minor, compatible })
|
||||
}
|
||||
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::{
|
||||
atoms::{Atom, AtomHeader},
|
||||
fourcc::FourCc,
|
||||
};
|
||||
|
||||
use log::warn;
|
||||
|
||||
/// Handler type.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum HandlerType {
|
||||
/// Video handler.
|
||||
Video,
|
||||
/// Audio handler.
|
||||
Sound,
|
||||
/// Subtitle handler.
|
||||
Subtitle,
|
||||
/// Metadata handler.
|
||||
Metadata,
|
||||
/// Text handler.
|
||||
Text,
|
||||
/// Unknown handler type.
|
||||
Other([u8; 4]),
|
||||
}
|
||||
|
||||
/// Handler atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct HdlrAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Handler type.
|
||||
pub handler_type: HandlerType,
|
||||
/// Human-readable handler name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Atom for HdlrAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
// Always 0 for MP4, but for Quicktime this contains the component type.
|
||||
let _ = reader.read_quad_bytes()?;
|
||||
|
||||
let handler_type = match &reader.read_quad_bytes()? {
|
||||
b"vide" => HandlerType::Video,
|
||||
b"soun" => HandlerType::Sound,
|
||||
b"meta" => HandlerType::Metadata,
|
||||
b"subt" => HandlerType::Subtitle,
|
||||
b"text" => HandlerType::Text,
|
||||
&hdlr => {
|
||||
warn!("unknown handler type {:?}", FourCc::new(hdlr));
|
||||
HandlerType::Other(hdlr)
|
||||
}
|
||||
};
|
||||
|
||||
// These bytes are reserved for MP4, but for QuickTime they contain the component
|
||||
// manufacturer, flags, and flags mask.
|
||||
reader.ignore_bytes(4 * 3)?;
|
||||
|
||||
// Human readable UTF-8 string of the track type.
|
||||
let buf = reader.read_boxed_slice_exact((header.data_len - 24) as usize)?;
|
||||
let name = String::from_utf8_lossy(&buf).to_string();
|
||||
|
||||
Ok(HdlrAtom { header, handler_type, name })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::{BufReader, ReadBytes};
|
||||
use symphonia_core::meta::{
|
||||
MetadataBuilder, MetadataRevision, StandardTagKey, StandardVisualKey, Tag,
|
||||
};
|
||||
use symphonia_core::meta::{Value, Visual};
|
||||
use symphonia_core::util::bits;
|
||||
use symphonia_metadata::{id3v1, itunes};
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
|
||||
|
||||
use encoding_rs::{SHIFT_JIS, UTF_16BE};
|
||||
use log::warn;
|
||||
|
||||
/// Data type enumeration for metadata value atoms as defined in the QuickTime File Format standard.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum DataType {
|
||||
AffineTransformF64,
|
||||
Bmp,
|
||||
DimensionsF32,
|
||||
Float32,
|
||||
Float64,
|
||||
Jpeg,
|
||||
/// The data type is implicit to the atom.
|
||||
NoType,
|
||||
Png,
|
||||
PointF32,
|
||||
QuickTimeMetadata,
|
||||
RectF32,
|
||||
ShiftJis,
|
||||
SignedInt16,
|
||||
SignedInt32,
|
||||
SignedInt64,
|
||||
SignedInt8,
|
||||
SignedIntVariable,
|
||||
UnsignedInt16,
|
||||
UnsignedInt32,
|
||||
UnsignedInt64,
|
||||
UnsignedInt8,
|
||||
UnsignedIntVariable,
|
||||
Utf16,
|
||||
Utf16Sort,
|
||||
Utf8,
|
||||
Utf8Sort,
|
||||
Unknown(u32),
|
||||
}
|
||||
|
||||
impl From<u32> for DataType {
|
||||
fn from(value: u32) -> Self {
|
||||
match value {
|
||||
0 => DataType::NoType,
|
||||
1 => DataType::Utf8,
|
||||
2 => DataType::Utf16,
|
||||
3 => DataType::ShiftJis,
|
||||
4 => DataType::Utf8Sort,
|
||||
5 => DataType::Utf16Sort,
|
||||
13 => DataType::Jpeg,
|
||||
14 => DataType::Png,
|
||||
21 => DataType::SignedIntVariable,
|
||||
22 => DataType::UnsignedIntVariable,
|
||||
23 => DataType::Float32,
|
||||
24 => DataType::Float64,
|
||||
27 => DataType::Bmp,
|
||||
28 => DataType::QuickTimeMetadata,
|
||||
65 => DataType::SignedInt8,
|
||||
66 => DataType::SignedInt16,
|
||||
67 => DataType::SignedInt32,
|
||||
70 => DataType::PointF32,
|
||||
71 => DataType::DimensionsF32,
|
||||
72 => DataType::RectF32,
|
||||
74 => DataType::SignedInt64,
|
||||
75 => DataType::UnsignedInt8,
|
||||
76 => DataType::UnsignedInt16,
|
||||
77 => DataType::UnsignedInt32,
|
||||
78 => DataType::UnsignedInt64,
|
||||
79 => DataType::AffineTransformF64,
|
||||
_ => DataType::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_no_type(data: &[u8]) -> Option<Value> {
|
||||
// Latin1, potentially null-terminated.
|
||||
let end = data.iter().position(|&c| c == b'\0').unwrap_or(data.len());
|
||||
let text = String::from_utf8_lossy(&data[..end]);
|
||||
Some(Value::from(text))
|
||||
}
|
||||
|
||||
fn parse_utf8(data: &[u8]) -> Option<Value> {
|
||||
// UTF8, no null-terminator or count.
|
||||
let text = String::from_utf8_lossy(data);
|
||||
Some(Value::from(text))
|
||||
}
|
||||
|
||||
fn parse_utf16(data: &[u8]) -> Option<Value> {
|
||||
// UTF16 BE
|
||||
let text = UTF_16BE.decode(data).0;
|
||||
Some(Value::from(text))
|
||||
}
|
||||
|
||||
fn parse_shift_jis(data: &[u8]) -> Option<Value> {
|
||||
// Shift-JIS
|
||||
let text = SHIFT_JIS.decode(data).0;
|
||||
Some(Value::from(text))
|
||||
}
|
||||
|
||||
fn parse_signed_int8(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
1 => {
|
||||
let s = bits::sign_extend_leq8_to_i8(data[0], 8);
|
||||
Some(Value::from(s))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_signed_int16(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
2 => {
|
||||
let u = BufReader::new(data).read_be_u16().ok()?;
|
||||
let s = bits::sign_extend_leq16_to_i16(u, 16);
|
||||
Some(Value::from(s))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_signed_int32(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
4 => {
|
||||
let u = BufReader::new(data).read_be_u32().ok()?;
|
||||
let s = bits::sign_extend_leq32_to_i32(u, 32);
|
||||
Some(Value::from(s))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_signed_int64(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
8 => {
|
||||
let u = BufReader::new(data).read_be_u64().ok()?;
|
||||
let s = bits::sign_extend_leq64_to_i64(u, 64);
|
||||
Some(Value::from(s))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_var_signed_int(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
1 => parse_signed_int8(data),
|
||||
2 => parse_signed_int16(data),
|
||||
4 => parse_signed_int32(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unsigned_int8(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
1 => Some(Value::from(data[0])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unsigned_int16(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
2 => {
|
||||
let u = BufReader::new(data).read_be_u16().ok()?;
|
||||
Some(Value::from(u))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unsigned_int32(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
4 => {
|
||||
let u = BufReader::new(data).read_be_u32().ok()?;
|
||||
Some(Value::from(u))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unsigned_int64(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
8 => {
|
||||
let u = BufReader::new(data).read_be_u64().ok()?;
|
||||
Some(Value::from(u))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_var_unsigned_int(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
1 => parse_unsigned_int8(data),
|
||||
2 => parse_unsigned_int16(data),
|
||||
4 => parse_unsigned_int32(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_float32(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
4 => {
|
||||
let f = BufReader::new(data).read_be_f32().ok()?;
|
||||
Some(Value::Float(f64::from(f)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_float64(data: &[u8]) -> Option<Value> {
|
||||
match data.len() {
|
||||
8 => {
|
||||
let f = BufReader::new(data).read_be_f64().ok()?;
|
||||
Some(Value::Float(f))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tag_value(data_type: DataType, data: &[u8]) -> Option<Value> {
|
||||
match data_type {
|
||||
DataType::NoType => parse_no_type(data),
|
||||
DataType::Utf8 | DataType::Utf8Sort => parse_utf8(data),
|
||||
DataType::Utf16 | DataType::Utf16Sort => parse_utf16(data),
|
||||
DataType::ShiftJis => parse_shift_jis(data),
|
||||
DataType::UnsignedInt8 => parse_unsigned_int8(data),
|
||||
DataType::UnsignedInt16 => parse_unsigned_int16(data),
|
||||
DataType::UnsignedInt32 => parse_unsigned_int32(data),
|
||||
DataType::UnsignedInt64 => parse_unsigned_int64(data),
|
||||
DataType::UnsignedIntVariable => parse_var_unsigned_int(data),
|
||||
DataType::SignedInt8 => parse_signed_int8(data),
|
||||
DataType::SignedInt16 => parse_signed_int16(data),
|
||||
DataType::SignedInt32 => parse_signed_int32(data),
|
||||
DataType::SignedInt64 => parse_signed_int64(data),
|
||||
DataType::SignedIntVariable => parse_var_signed_int(data),
|
||||
DataType::Float32 => parse_float32(data),
|
||||
DataType::Float64 => parse_float64(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and parses a `MetaTagAtom` from the provided iterator and adds it to the `MetadataBuilder`
|
||||
/// if there are no errors.
|
||||
fn add_generic_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
std_key: Option<StandardTagKey>,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
for value_atom in tag.values.iter() {
|
||||
// Parse the value atom data into a string, if possible.
|
||||
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
|
||||
builder.add_tag(Tag::new(std_key, "", value));
|
||||
}
|
||||
else {
|
||||
warn!("unsupported data type {:?} for {:?} tag", value_atom.data_type, std_key);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_var_unsigned_int_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
std_key: StandardTagKey,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
if let Some(value_atom) = tag.values.first() {
|
||||
if let Some(value) = parse_var_unsigned_int(&value_atom.data) {
|
||||
builder.add_tag(Tag::new(Some(std_key), "", value));
|
||||
}
|
||||
else {
|
||||
warn!("got unexpected data for {:?} tag", std_key);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_var_signed_int_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
std_key: StandardTagKey,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
if let Some(value_atom) = tag.values.first() {
|
||||
if let Some(value) = parse_var_signed_int(&value_atom.data) {
|
||||
builder.add_tag(Tag::new(Some(std_key), "", value));
|
||||
}
|
||||
else {
|
||||
warn!("got unexpected data for {:?} tag", std_key);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_boolean_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
std_key: StandardTagKey,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// There should only be 1 value.
|
||||
if let Some(value) = tag.values.first() {
|
||||
// Boolean tags are just "flags", only add a tag if the boolean is true (1).
|
||||
if let Some(bool_value) = value.data.first() {
|
||||
if *bool_value == 1 {
|
||||
builder.add_tag(Tag::new(Some(std_key), "", Value::Flag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_m_of_n_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
m_key: StandardTagKey,
|
||||
n_key: StandardTagKey,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// There should only be 1 value.
|
||||
if let Some(value) = tag.values.first() {
|
||||
// The trkn and disk atoms contains an 8 byte value buffer, where the 4th and 6th bytes
|
||||
// indicate the track/disk number and total number of tracks/disks, respectively. Odd.
|
||||
if value.data.len() == 8 {
|
||||
let m = value.data[3];
|
||||
let n = value.data[5];
|
||||
|
||||
builder.add_tag(Tag::new(Some(m_key), "", Value::from(m)));
|
||||
builder.add_tag(Tag::new(Some(n_key), "", Value::from(n)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_visual_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// There could be more than one attached image.
|
||||
for value in tag.values {
|
||||
let media_type = match value.data_type {
|
||||
DataType::Bmp => "image/bmp",
|
||||
DataType::Jpeg => "image/jpeg",
|
||||
DataType::Png => "image/png",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
builder.add_visual(Visual {
|
||||
media_type: media_type.into(),
|
||||
dimensions: None,
|
||||
bits_per_pixel: None,
|
||||
color_mode: None,
|
||||
usage: Some(StandardVisualKey::FrontCover),
|
||||
tags: Default::default(),
|
||||
data: value.data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_advisory_tag<B: ReadBytes>(
|
||||
_iter: &mut AtomIterator<B>,
|
||||
_builder: &mut MetadataBuilder,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_media_type_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// There should only be 1 value.
|
||||
if let Some(value) = tag.values.first() {
|
||||
if let Some(media_type_value) = value.data.first() {
|
||||
let media_type = match media_type_value {
|
||||
0 => "Movie",
|
||||
1 => "Normal",
|
||||
2 => "Audio Book",
|
||||
5 => "Whacked Bookmark",
|
||||
6 => "Music Video",
|
||||
9 => "Short Film",
|
||||
10 => "TV Show",
|
||||
11 => "Booklet",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
builder.add_tag(Tag::new(
|
||||
Some(StandardTagKey::MediaFormat),
|
||||
"",
|
||||
Value::from(media_type),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_id3v1_genre_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// There should only be 1 value.
|
||||
if let Some(value) = tag.values.first() {
|
||||
// The ID3v1 genre is stored as a unsigned 16-bit big-endian integer.
|
||||
let index = BufReader::new(&value.data).read_be_u16()?;
|
||||
|
||||
// The stored index uses 1-based indexing, but the ID3v1 genre list is 0-based.
|
||||
if index > 0 {
|
||||
if let Some(genre) = id3v1::util::genre_name((index - 1) as u8) {
|
||||
builder.add_tag(Tag::new(Some(StandardTagKey::Genre), "", Value::from(*genre)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_freeform_tag<B: ReadBytes>(
|
||||
iter: &mut AtomIterator<B>,
|
||||
builder: &mut MetadataBuilder,
|
||||
) -> Result<()> {
|
||||
let tag = iter.read_atom::<MetaTagAtom>()?;
|
||||
|
||||
// A user-defined tag should only have 1 value.
|
||||
for value_atom in tag.values.iter() {
|
||||
// Parse the value atom data into a string, if possible.
|
||||
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
|
||||
// Gets the fully qualified tag name.
|
||||
let full_name = tag.full_name();
|
||||
|
||||
// Try to map iTunes freeform tags to standard tag keys.
|
||||
let std_key = itunes::std_key_from_tag(&full_name);
|
||||
|
||||
builder.add_tag(Tag::new(std_key, &full_name, value));
|
||||
}
|
||||
else {
|
||||
warn!("unsupported data type {:?} for free-form tag", value_atom.data_type);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Metadata tag data atom.
|
||||
#[allow(dead_code)]
|
||||
pub struct MetaTagDataAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Tag data.
|
||||
pub data: Box<[u8]>,
|
||||
/// The data type contained in buf.
|
||||
pub data_type: DataType,
|
||||
}
|
||||
|
||||
impl Atom for MetaTagDataAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
// For the mov brand, this a data type indicator and must always be 0 (well-known type). It
|
||||
// specifies the table in which the next 24-bit integer specifying the actual data type
|
||||
// indexes. For iso/mp4, this is a version, and there is only one version, 0. Therefore,
|
||||
// flags are interpreted as the actual data type index.
|
||||
if version != 0 {
|
||||
return decode_error("isomp4: invalid data atom version");
|
||||
}
|
||||
|
||||
let data_type = DataType::from(flags);
|
||||
|
||||
// For the mov brand, the next four bytes are country and languages code. However, for
|
||||
// iso/mp4 these codes should be ignored.
|
||||
let _country = reader.read_be_u16()?;
|
||||
let _language = reader.read_be_u16()?;
|
||||
|
||||
// The data payload is the remainder of the atom.
|
||||
// TODO: Apply a limit.
|
||||
let data = reader
|
||||
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE - 4) as usize)?;
|
||||
|
||||
Ok(MetaTagDataAtom { header, data, data_type })
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata tag name and mean atom.
|
||||
#[allow(dead_code)]
|
||||
pub struct MetaTagNamespaceAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// For 'mean' atoms, this is the key namespace. For 'name' atom, this is the key name.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Atom for MetaTagNamespaceAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let buf = reader
|
||||
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE) as usize)?;
|
||||
|
||||
// Do a lossy conversion because metadata should not prevent the demuxer from working.
|
||||
let value = String::from_utf8_lossy(&buf).to_string();
|
||||
|
||||
Ok(MetaTagNamespaceAtom { header, value })
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic metadata tag atom.
|
||||
#[allow(dead_code)]
|
||||
pub struct MetaTagAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Tag value(s).
|
||||
pub values: Vec<MetaTagDataAtom>,
|
||||
/// Optional, tag key namespace.
|
||||
pub mean: Option<MetaTagNamespaceAtom>,
|
||||
/// Optional, tag key name.
|
||||
pub name: Option<MetaTagNamespaceAtom>,
|
||||
}
|
||||
|
||||
impl MetaTagAtom {
|
||||
pub fn full_name(&self) -> String {
|
||||
let mut full_name = String::new();
|
||||
|
||||
if self.mean.is_some() || self.name.is_some() {
|
||||
// full_name.push_str("----:");
|
||||
|
||||
if let Some(mean) = &self.mean {
|
||||
full_name.push_str(&mean.value);
|
||||
}
|
||||
|
||||
full_name.push(':');
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
full_name.push_str(&name.value);
|
||||
}
|
||||
}
|
||||
|
||||
full_name
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for MetaTagAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut mean = None;
|
||||
let mut name = None;
|
||||
let mut values = Vec::new();
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MetaTagData => {
|
||||
values.push(iter.read_atom::<MetaTagDataAtom>()?);
|
||||
}
|
||||
AtomType::MetaTagName => {
|
||||
name = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
|
||||
}
|
||||
AtomType::MetaTagMeaning => {
|
||||
mean = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MetaTagAtom { header, values, mean, name })
|
||||
}
|
||||
}
|
||||
|
||||
/// User data atom.
|
||||
#[allow(dead_code)]
|
||||
pub struct IlstAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Metadata revision.
|
||||
pub metadata: MetadataRevision,
|
||||
}
|
||||
|
||||
impl Atom for IlstAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut mb = MetadataBuilder::new();
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
// Ignore standard atoms, check if other is a metadata atom.
|
||||
match &header.atype {
|
||||
AtomType::AdvisoryTag => add_advisory_tag(&mut iter, &mut mb)?,
|
||||
AtomType::AlbumArtistTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::AlbumArtist))?
|
||||
}
|
||||
AtomType::AlbumTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Album))?
|
||||
}
|
||||
AtomType::ArtistLowerTag => (),
|
||||
AtomType::ArtistTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Artist))?
|
||||
}
|
||||
AtomType::CategoryTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastCategory))?
|
||||
}
|
||||
AtomType::CommentTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Comment))?
|
||||
}
|
||||
AtomType::CompilationTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Compilation))?
|
||||
}
|
||||
AtomType::ComposerTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Composer))?
|
||||
}
|
||||
AtomType::CopyrightTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Copyright))?
|
||||
}
|
||||
AtomType::CoverTag => add_visual_tag(&mut iter, &mut mb)?,
|
||||
AtomType::CustomGenreTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Genre))?
|
||||
}
|
||||
AtomType::DateTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Date))?
|
||||
}
|
||||
AtomType::DescriptionTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
|
||||
}
|
||||
AtomType::DiskNumberTag => add_m_of_n_tag(
|
||||
&mut iter,
|
||||
&mut mb,
|
||||
StandardTagKey::DiscNumber,
|
||||
StandardTagKey::DiscTotal,
|
||||
)?,
|
||||
AtomType::EncodedByTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::EncodedBy))?
|
||||
}
|
||||
AtomType::EncoderTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Encoder))?
|
||||
}
|
||||
AtomType::GaplessPlaybackTag => {
|
||||
// TODO: Need standard tag key for gapless playback.
|
||||
// add_boolean_tag(&mut iter, &mut mb, )?
|
||||
}
|
||||
AtomType::GenreTag => add_id3v1_genre_tag(&mut iter, &mut mb)?,
|
||||
AtomType::GroupingTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::ContentGroup))?
|
||||
}
|
||||
AtomType::HdVideoTag => (),
|
||||
AtomType::IdentPodcastTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::IdentPodcast))?
|
||||
}
|
||||
AtomType::KeywordTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastKeywords))?
|
||||
}
|
||||
AtomType::LongDescriptionTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
|
||||
}
|
||||
AtomType::LyricsTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Lyrics))?
|
||||
}
|
||||
AtomType::MediaTypeTag => add_media_type_tag(&mut iter, &mut mb)?,
|
||||
AtomType::OwnerTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Owner))?
|
||||
}
|
||||
AtomType::PodcastTag => {
|
||||
add_boolean_tag(&mut iter, &mut mb, StandardTagKey::Podcast)?
|
||||
}
|
||||
AtomType::PurchaseDateTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PurchaseDate))?
|
||||
}
|
||||
AtomType::RatingTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Rating))?
|
||||
}
|
||||
AtomType::SortAlbumArtistTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbumArtist))?
|
||||
}
|
||||
AtomType::SortAlbumTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbum))?
|
||||
}
|
||||
AtomType::SortArtistTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortArtist))?
|
||||
}
|
||||
AtomType::SortComposerTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortComposer))?
|
||||
}
|
||||
AtomType::SortNameTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortTrackTitle))?
|
||||
}
|
||||
AtomType::TempoTag => {
|
||||
add_var_signed_int_tag(&mut iter, &mut mb, StandardTagKey::Bpm)?
|
||||
}
|
||||
AtomType::TrackNumberTag => add_m_of_n_tag(
|
||||
&mut iter,
|
||||
&mut mb,
|
||||
StandardTagKey::TrackNumber,
|
||||
StandardTagKey::TrackTotal,
|
||||
)?,
|
||||
AtomType::TrackTitleTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TrackTitle))?
|
||||
}
|
||||
AtomType::TvEpisodeNameTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvEpisodeTitle))?
|
||||
}
|
||||
AtomType::TvEpisodeNumberTag => {
|
||||
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvEpisode)?
|
||||
}
|
||||
AtomType::TvNetworkNameTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvNetwork))?
|
||||
}
|
||||
AtomType::TvSeasonNumberTag => {
|
||||
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvSeason)?
|
||||
}
|
||||
AtomType::TvShowNameTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvShowTitle))?
|
||||
}
|
||||
AtomType::UrlPodcastTag => {
|
||||
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::UrlPodcast))?
|
||||
}
|
||||
AtomType::FreeFormTag => add_freeform_tag(&mut iter, &mut mb)?,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(IlstAtom { header, metadata: mb.metadata() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
fn parse_language(code: u16) -> String {
|
||||
// An ISO language code outside of these bounds is not valid.
|
||||
if code < 0x400 || code > 0x7fff {
|
||||
String::new()
|
||||
}
|
||||
else {
|
||||
let chars = [
|
||||
((code >> 10) & 0x1f) as u8 + 0x60,
|
||||
((code >> 5) & 0x1f) as u8 + 0x60,
|
||||
((code >> 0) & 0x1f) as u8 + 0x60,
|
||||
];
|
||||
|
||||
String::from_utf8_lossy(&chars).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Media header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MdhdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Creation time.
|
||||
pub ctime: u64,
|
||||
/// Modification time.
|
||||
pub mtime: u64,
|
||||
/// Timescale.
|
||||
pub timescale: u32,
|
||||
/// Duration of the media in timescale units.
|
||||
pub duration: u64,
|
||||
/// Language.
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Atom for MdhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let mut mdhd = MdhdAtom {
|
||||
header,
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
timescale: 0,
|
||||
duration: 0,
|
||||
language: String::new(),
|
||||
};
|
||||
|
||||
match version {
|
||||
0 => {
|
||||
mdhd.ctime = u64::from(reader.read_be_u32()?);
|
||||
mdhd.mtime = u64::from(reader.read_be_u32()?);
|
||||
mdhd.timescale = reader.read_be_u32()?;
|
||||
// 0xffff_ffff is a special case.
|
||||
mdhd.duration = match reader.read_be_u32()? {
|
||||
u32::MAX => u64::MAX,
|
||||
duration => u64::from(duration),
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
mdhd.ctime = reader.read_be_u64()?;
|
||||
mdhd.mtime = reader.read_be_u64()?;
|
||||
mdhd.timescale = reader.read_be_u32()?;
|
||||
mdhd.duration = reader.read_be_u64()?;
|
||||
}
|
||||
_ => {
|
||||
return decode_error("isomp4: invalid mdhd version");
|
||||
}
|
||||
}
|
||||
|
||||
mdhd.language = parse_language(reader.read_be_u16()?);
|
||||
|
||||
// Quality
|
||||
let _ = reader.read_be_u16()?;
|
||||
|
||||
Ok(mdhd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, HdlrAtom, MdhdAtom, MinfAtom};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MdiaAtom {
|
||||
header: AtomHeader,
|
||||
pub mdhd: MdhdAtom,
|
||||
pub hdlr: HdlrAtom,
|
||||
pub minf: MinfAtom,
|
||||
}
|
||||
|
||||
impl Atom for MdiaAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut mdhd = None;
|
||||
let mut hdlr = None;
|
||||
let mut minf = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MediaHeader => {
|
||||
mdhd = Some(iter.read_atom::<MdhdAtom>()?);
|
||||
}
|
||||
AtomType::Handler => {
|
||||
hdlr = Some(iter.read_atom::<HdlrAtom>()?);
|
||||
}
|
||||
AtomType::MediaInfo => {
|
||||
minf = Some(iter.read_atom::<MinfAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if mdhd.is_none() {
|
||||
return decode_error("isomp4: missing mdhd atom");
|
||||
}
|
||||
|
||||
if hdlr.is_none() {
|
||||
return decode_error("isomp4: missing hdlr atom");
|
||||
}
|
||||
|
||||
if minf.is_none() {
|
||||
return decode_error("isomp4: missing minf atom");
|
||||
}
|
||||
|
||||
Ok(MdiaAtom { header, mdhd: mdhd.unwrap(), hdlr: hdlr.unwrap(), minf: minf.unwrap() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Movie extends header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MehdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Fragment duration.
|
||||
pub fragment_duration: u64,
|
||||
}
|
||||
|
||||
impl Atom for MehdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let fragment_duration = match version {
|
||||
0 => u64::from(reader.read_be_u32()?),
|
||||
1 => reader.read_be_u64()?,
|
||||
_ => {
|
||||
return decode_error("isomp4: invalid mehd version");
|
||||
}
|
||||
};
|
||||
|
||||
Ok(MehdAtom { header, fragment_duration })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
use symphonia_core::meta::MetadataRevision;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, IlstAtom};
|
||||
|
||||
/// User data atom.
|
||||
#[allow(dead_code)]
|
||||
pub struct MetaAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Metadata revision.
|
||||
pub metadata: Option<MetadataRevision>,
|
||||
}
|
||||
|
||||
impl Debug for MetaAtom {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "(redacted)")
|
||||
}
|
||||
}
|
||||
|
||||
impl MetaAtom {
|
||||
/// If metadata was read, consumes the metadata and returns it.
|
||||
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
|
||||
self.metadata.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for MetaAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
#[allow(clippy::single_match)]
|
||||
fn read<B: ReadBytes>(reader: &mut B, mut header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
// AtomIterator doesn't know the extra data was read already, so the extra data size must be
|
||||
// subtrated from the atom's data length.
|
||||
header.data_len -= AtomHeader::EXTRA_DATA_SIZE;
|
||||
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut metadata = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MetaList => {
|
||||
metadata = Some(iter.read_atom::<IlstAtom>()?.metadata);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MetaAtom { header, metadata })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Movie fragment header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MfhdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Sequence number associated with fragment.
|
||||
pub sequence_number: u32,
|
||||
}
|
||||
|
||||
impl Atom for MfhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let sequence_number = reader.read_be_u32()?;
|
||||
|
||||
Ok(MfhdAtom { header, sequence_number })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, SmhdAtom, StblAtom};
|
||||
|
||||
/// Media information atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MinfAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Sound media header atom.
|
||||
pub smhd: Option<SmhdAtom>,
|
||||
/// Sample table atom.
|
||||
pub stbl: StblAtom,
|
||||
}
|
||||
|
||||
impl Atom for MinfAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut smhd = None;
|
||||
let mut stbl = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::SoundMediaHeader => {
|
||||
smhd = Some(iter.read_atom::<SmhdAtom>()?);
|
||||
}
|
||||
AtomType::SampleTable => {
|
||||
stbl = Some(iter.read_atom::<StblAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if stbl.is_none() {
|
||||
return decode_error("isomp4: missing stbl atom");
|
||||
}
|
||||
|
||||
Ok(MinfAtom { header, smhd, stbl: stbl.unwrap() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
pub(crate) mod alac;
|
||||
pub(crate) mod co64;
|
||||
pub(crate) mod ctts;
|
||||
pub(crate) mod edts;
|
||||
pub(crate) mod elst;
|
||||
pub(crate) mod esds;
|
||||
pub(crate) mod flac;
|
||||
pub(crate) mod ftyp;
|
||||
pub(crate) mod hdlr;
|
||||
pub(crate) mod ilst;
|
||||
pub(crate) mod mdhd;
|
||||
pub(crate) mod mdia;
|
||||
pub(crate) mod mehd;
|
||||
pub(crate) mod meta;
|
||||
pub(crate) mod mfhd;
|
||||
pub(crate) mod minf;
|
||||
pub(crate) mod moof;
|
||||
pub(crate) mod moov;
|
||||
pub(crate) mod mvex;
|
||||
pub(crate) mod mvhd;
|
||||
pub(crate) mod opus;
|
||||
pub(crate) mod sidx;
|
||||
pub(crate) mod smhd;
|
||||
pub(crate) mod stbl;
|
||||
pub(crate) mod stco;
|
||||
pub(crate) mod stsc;
|
||||
pub(crate) mod stsd;
|
||||
pub(crate) mod stss;
|
||||
pub(crate) mod stsz;
|
||||
pub(crate) mod stts;
|
||||
pub(crate) mod tfhd;
|
||||
pub(crate) mod tkhd;
|
||||
pub(crate) mod traf;
|
||||
pub(crate) mod trak;
|
||||
pub(crate) mod trex;
|
||||
pub(crate) mod trun;
|
||||
pub(crate) mod udta;
|
||||
pub(crate) mod wave;
|
||||
|
||||
pub use self::meta::MetaAtom;
|
||||
pub use alac::AlacAtom;
|
||||
pub use co64::Co64Atom;
|
||||
#[allow(unused_imports)]
|
||||
pub use ctts::CttsAtom;
|
||||
pub use edts::EdtsAtom;
|
||||
pub use elst::ElstAtom;
|
||||
pub use esds::EsdsAtom;
|
||||
pub use flac::FlacAtom;
|
||||
pub use ftyp::FtypAtom;
|
||||
pub use hdlr::HdlrAtom;
|
||||
pub use ilst::IlstAtom;
|
||||
pub use mdhd::MdhdAtom;
|
||||
pub use mdia::MdiaAtom;
|
||||
pub use mehd::MehdAtom;
|
||||
pub use mfhd::MfhdAtom;
|
||||
pub use minf::MinfAtom;
|
||||
pub use moof::MoofAtom;
|
||||
pub use moov::MoovAtom;
|
||||
pub use mvex::MvexAtom;
|
||||
pub use mvhd::MvhdAtom;
|
||||
pub use opus::OpusAtom;
|
||||
pub use sidx::SidxAtom;
|
||||
pub use smhd::SmhdAtom;
|
||||
pub use stbl::StblAtom;
|
||||
pub use stco::StcoAtom;
|
||||
pub use stsc::StscAtom;
|
||||
pub use stsd::StsdAtom;
|
||||
#[allow(unused_imports)]
|
||||
pub use stss::StssAtom;
|
||||
pub use stsz::StszAtom;
|
||||
pub use stts::SttsAtom;
|
||||
pub use tfhd::TfhdAtom;
|
||||
pub use tkhd::TkhdAtom;
|
||||
pub use traf::TrafAtom;
|
||||
pub use trak::TrakAtom;
|
||||
pub use trex::TrexAtom;
|
||||
pub use trun::TrunAtom;
|
||||
pub use udta::UdtaAtom;
|
||||
pub use wave::WaveAtom;
|
||||
|
||||
/// Atom types.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AtomType {
|
||||
Ac3,
|
||||
AdvisoryTag,
|
||||
Alac,
|
||||
ALaw,
|
||||
AlbumArtistTag,
|
||||
AlbumTag,
|
||||
ArtistLowerTag,
|
||||
ArtistTag,
|
||||
CategoryTag,
|
||||
ChunkOffset,
|
||||
ChunkOffset64,
|
||||
CommentTag,
|
||||
CompilationTag,
|
||||
ComposerTag,
|
||||
CompositionTimeToSample,
|
||||
CopyrightTag,
|
||||
CoverTag,
|
||||
CustomGenreTag,
|
||||
DateTag,
|
||||
DescriptionTag,
|
||||
DiskNumberTag,
|
||||
Edit,
|
||||
EditList,
|
||||
EncodedByTag,
|
||||
EncoderTag,
|
||||
Esds,
|
||||
F32SampleEntry,
|
||||
F64SampleEntry,
|
||||
FileType,
|
||||
Flac,
|
||||
FlacDsConfig,
|
||||
Free,
|
||||
FreeFormTag,
|
||||
GaplessPlaybackTag,
|
||||
GenreTag,
|
||||
GroupingTag,
|
||||
Handler,
|
||||
HdVideoTag,
|
||||
IdentPodcastTag,
|
||||
KeywordTag,
|
||||
LongDescriptionTag,
|
||||
Lpcm,
|
||||
LyricsTag,
|
||||
Media,
|
||||
MediaData,
|
||||
MediaHeader,
|
||||
MediaInfo,
|
||||
MediaTypeTag,
|
||||
Meta,
|
||||
MetaList,
|
||||
MetaTagData,
|
||||
MetaTagMeaning,
|
||||
MetaTagName,
|
||||
Movie,
|
||||
MovieExtends,
|
||||
MovieExtendsHeader,
|
||||
MovieFragment,
|
||||
MovieFragmentHeader,
|
||||
MovieHeader,
|
||||
Mp3,
|
||||
Mp4a,
|
||||
MuLaw,
|
||||
Opus,
|
||||
OpusDsConfig,
|
||||
OwnerTag,
|
||||
PodcastTag,
|
||||
PurchaseDateTag,
|
||||
QtWave,
|
||||
RatingTag,
|
||||
S16BeSampleEntry,
|
||||
S16LeSampleEntry,
|
||||
S24SampleEntry,
|
||||
S32SampleEntry,
|
||||
SampleDescription,
|
||||
SampleSize,
|
||||
SampleTable,
|
||||
SampleToChunk,
|
||||
SegmentIndex,
|
||||
Skip,
|
||||
SortAlbumArtistTag,
|
||||
SortAlbumTag,
|
||||
SortArtistTag,
|
||||
SortComposerTag,
|
||||
SortNameTag,
|
||||
SoundMediaHeader,
|
||||
SyncSample,
|
||||
TempoTag,
|
||||
TimeToSample,
|
||||
Track,
|
||||
TrackExtends,
|
||||
TrackFragment,
|
||||
TrackFragmentHeader,
|
||||
TrackFragmentRun,
|
||||
TrackHeader,
|
||||
TrackNumberTag,
|
||||
TrackTitleTag,
|
||||
TvEpisodeNameTag,
|
||||
TvEpisodeNumberTag,
|
||||
TvNetworkNameTag,
|
||||
TvSeasonNumberTag,
|
||||
TvShowNameTag,
|
||||
U8SampleEntry,
|
||||
UrlPodcastTag,
|
||||
UserData,
|
||||
Other([u8; 4]),
|
||||
}
|
||||
|
||||
impl From<[u8; 4]> for AtomType {
|
||||
fn from(val: [u8; 4]) -> Self {
|
||||
match &val {
|
||||
b".mp3" => AtomType::Mp3,
|
||||
b"ac-3" => AtomType::Ac3,
|
||||
b"alac" => AtomType::Alac,
|
||||
b"alaw" => AtomType::ALaw,
|
||||
b"co64" => AtomType::ChunkOffset64,
|
||||
b"ctts" => AtomType::CompositionTimeToSample,
|
||||
b"data" => AtomType::MetaTagData,
|
||||
b"dfLa" => AtomType::FlacDsConfig,
|
||||
b"dOps" => AtomType::OpusDsConfig,
|
||||
b"edts" => AtomType::Edit,
|
||||
b"elst" => AtomType::EditList,
|
||||
b"esds" => AtomType::Esds,
|
||||
b"fl32" => AtomType::F32SampleEntry,
|
||||
b"fl64" => AtomType::F64SampleEntry,
|
||||
b"fLaC" => AtomType::Flac,
|
||||
b"free" => AtomType::Free,
|
||||
b"ftyp" => AtomType::FileType,
|
||||
b"hdlr" => AtomType::Handler,
|
||||
b"ilst" => AtomType::MetaList,
|
||||
b"in24" => AtomType::S24SampleEntry,
|
||||
b"in32" => AtomType::S32SampleEntry,
|
||||
b"lpcm" => AtomType::Lpcm,
|
||||
b"mdat" => AtomType::MediaData,
|
||||
b"mdhd" => AtomType::MediaHeader,
|
||||
b"mdia" => AtomType::Media,
|
||||
b"mean" => AtomType::MetaTagMeaning,
|
||||
b"mehd" => AtomType::MovieExtendsHeader,
|
||||
b"meta" => AtomType::Meta,
|
||||
b"mfhd" => AtomType::MovieFragmentHeader,
|
||||
b"minf" => AtomType::MediaInfo,
|
||||
b"moof" => AtomType::MovieFragment,
|
||||
b"moov" => AtomType::Movie,
|
||||
b"mp4a" => AtomType::Mp4a,
|
||||
b"mvex" => AtomType::MovieExtends,
|
||||
b"mvhd" => AtomType::MovieHeader,
|
||||
b"name" => AtomType::MetaTagName,
|
||||
b"Opus" => AtomType::Opus,
|
||||
b"raw " => AtomType::U8SampleEntry,
|
||||
b"sidx" => AtomType::SegmentIndex,
|
||||
b"skip" => AtomType::Skip,
|
||||
b"smhd" => AtomType::SoundMediaHeader,
|
||||
b"sowt" => AtomType::S16LeSampleEntry,
|
||||
b"stbl" => AtomType::SampleTable,
|
||||
b"stco" => AtomType::ChunkOffset,
|
||||
b"stsc" => AtomType::SampleToChunk,
|
||||
b"stsd" => AtomType::SampleDescription,
|
||||
b"stss" => AtomType::SyncSample,
|
||||
b"stsz" => AtomType::SampleSize,
|
||||
b"stts" => AtomType::TimeToSample,
|
||||
b"tfhd" => AtomType::TrackFragmentHeader,
|
||||
b"tkhd" => AtomType::TrackHeader,
|
||||
b"traf" => AtomType::TrackFragment,
|
||||
b"trak" => AtomType::Track,
|
||||
b"trex" => AtomType::TrackExtends,
|
||||
b"trun" => AtomType::TrackFragmentRun,
|
||||
b"twos" => AtomType::S16BeSampleEntry,
|
||||
b"udta" => AtomType::UserData,
|
||||
b"ulaw" => AtomType::MuLaw,
|
||||
b"wave" => AtomType::QtWave,
|
||||
// Metadata Boxes
|
||||
b"----" => AtomType::FreeFormTag,
|
||||
b"aART" => AtomType::AlbumArtistTag,
|
||||
b"catg" => AtomType::CategoryTag,
|
||||
b"covr" => AtomType::CoverTag,
|
||||
b"cpil" => AtomType::CompilationTag,
|
||||
b"cprt" => AtomType::CopyrightTag,
|
||||
b"desc" => AtomType::DescriptionTag,
|
||||
b"disk" => AtomType::DiskNumberTag,
|
||||
b"egid" => AtomType::IdentPodcastTag,
|
||||
b"gnre" => AtomType::GenreTag,
|
||||
b"hdvd" => AtomType::HdVideoTag,
|
||||
b"keyw" => AtomType::KeywordTag,
|
||||
b"ldes" => AtomType::LongDescriptionTag,
|
||||
b"ownr" => AtomType::OwnerTag,
|
||||
b"pcst" => AtomType::PodcastTag,
|
||||
b"pgap" => AtomType::GaplessPlaybackTag,
|
||||
b"purd" => AtomType::PurchaseDateTag,
|
||||
b"purl" => AtomType::UrlPodcastTag,
|
||||
b"rate" => AtomType::RatingTag,
|
||||
b"rtng" => AtomType::AdvisoryTag,
|
||||
b"soaa" => AtomType::SortAlbumArtistTag,
|
||||
b"soal" => AtomType::SortAlbumTag,
|
||||
b"soar" => AtomType::SortArtistTag,
|
||||
b"soco" => AtomType::SortComposerTag,
|
||||
b"sonm" => AtomType::SortNameTag,
|
||||
b"stik" => AtomType::MediaTypeTag,
|
||||
b"tmpo" => AtomType::TempoTag,
|
||||
b"trkn" => AtomType::TrackNumberTag,
|
||||
b"tven" => AtomType::TvEpisodeNameTag,
|
||||
b"tves" => AtomType::TvEpisodeNumberTag,
|
||||
b"tvnn" => AtomType::TvNetworkNameTag,
|
||||
b"tvsh" => AtomType::TvShowNameTag,
|
||||
b"tvsn" => AtomType::TvSeasonNumberTag,
|
||||
b"\xa9alb" => AtomType::AlbumTag,
|
||||
b"\xa9art" => AtomType::ArtistLowerTag,
|
||||
b"\xa9ART" => AtomType::ArtistTag,
|
||||
b"\xa9cmt" => AtomType::CommentTag,
|
||||
b"\xa9day" => AtomType::DateTag,
|
||||
b"\xa9enc" => AtomType::EncodedByTag,
|
||||
b"\xa9gen" => AtomType::CustomGenreTag,
|
||||
b"\xa9grp" => AtomType::GroupingTag,
|
||||
b"\xa9lyr" => AtomType::LyricsTag,
|
||||
b"\xa9nam" => AtomType::TrackTitleTag,
|
||||
b"\xa9too" => AtomType::EncoderTag,
|
||||
b"\xa9wrt" => AtomType::ComposerTag,
|
||||
_ => AtomType::Other(val),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common atom header.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct AtomHeader {
|
||||
/// The atom type.
|
||||
pub atype: AtomType,
|
||||
/// The total size of the atom including the header.
|
||||
pub atom_len: u64,
|
||||
/// The size of the payload data.
|
||||
pub data_len: u64,
|
||||
}
|
||||
|
||||
impl AtomHeader {
|
||||
const HEADER_SIZE: u64 = 8;
|
||||
const EXTENDED_HEADER_SIZE: u64 = AtomHeader::HEADER_SIZE + 8;
|
||||
const EXTRA_DATA_SIZE: u64 = 4;
|
||||
|
||||
/// Reads an atom header from the provided `ByteStream`.
|
||||
pub fn read<B: ReadBytes>(reader: &mut B) -> Result<AtomHeader> {
|
||||
let mut atom_len = u64::from(reader.read_be_u32()?);
|
||||
let atype = AtomType::from(reader.read_quad_bytes()?);
|
||||
|
||||
let data_len = match atom_len {
|
||||
0 => 0,
|
||||
1 => {
|
||||
atom_len = reader.read_be_u64()?;
|
||||
|
||||
// The atom size should be atleast the length of the header.
|
||||
if atom_len < AtomHeader::EXTENDED_HEADER_SIZE {
|
||||
return decode_error("isomp4: atom size is invalid");
|
||||
}
|
||||
|
||||
atom_len - AtomHeader::EXTENDED_HEADER_SIZE
|
||||
}
|
||||
_ => {
|
||||
// The atom size should be atleast the length of the header.
|
||||
if atom_len < AtomHeader::HEADER_SIZE {
|
||||
return decode_error("isomp4: atom size is invalid");
|
||||
}
|
||||
|
||||
atom_len - AtomHeader::HEADER_SIZE
|
||||
}
|
||||
};
|
||||
|
||||
Ok(AtomHeader { atype, atom_len, data_len })
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn base_header_len(&self) -> u64 {
|
||||
match self.atom_len {
|
||||
0 => AtomHeader::HEADER_SIZE,
|
||||
_ => self.atom_len - self.data_len,
|
||||
}
|
||||
}
|
||||
|
||||
/// For applicable atoms, reads the atom header extra data: a tuple composed of a u8 version
|
||||
/// number, and a u24 bitset of flags.
|
||||
pub fn read_extra<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
|
||||
Ok((reader.read_u8()?, reader.read_be_u24()?))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Atom: Sized {
|
||||
#[allow(dead_code)]
|
||||
fn header(&self) -> AtomHeader;
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self>;
|
||||
}
|
||||
|
||||
pub struct AtomIterator<B: ReadBytes> {
|
||||
reader: B,
|
||||
len: Option<u64>,
|
||||
cur_atom: Option<AtomHeader>,
|
||||
base_pos: u64,
|
||||
next_atom_pos: u64,
|
||||
}
|
||||
|
||||
impl<B: ReadBytes> AtomIterator<B> {
|
||||
pub fn new_root(reader: B, len: Option<u64>) -> Self {
|
||||
let base_pos = reader.pos();
|
||||
|
||||
AtomIterator { reader, len, cur_atom: None, base_pos, next_atom_pos: base_pos }
|
||||
}
|
||||
|
||||
pub fn new(reader: B, container: AtomHeader) -> Self {
|
||||
let base_pos = reader.pos();
|
||||
|
||||
AtomIterator {
|
||||
reader,
|
||||
len: Some(container.data_len),
|
||||
cur_atom: None,
|
||||
base_pos,
|
||||
next_atom_pos: base_pos,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> B {
|
||||
self.reader
|
||||
}
|
||||
|
||||
pub fn inner_mut(&mut self) -> &mut B {
|
||||
&mut self.reader
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Result<Option<AtomHeader>> {
|
||||
// Ignore any remaining data in the current atom that was not read.
|
||||
let cur_pos = self.reader.pos();
|
||||
|
||||
if cur_pos < self.next_atom_pos {
|
||||
self.reader.ignore_bytes(self.next_atom_pos - cur_pos)?;
|
||||
}
|
||||
else if cur_pos > self.next_atom_pos {
|
||||
// This is very bad, either the atom's length was incorrect or the demuxer erroroneously
|
||||
// overread an atom.
|
||||
return decode_error("isomp4: overread atom");
|
||||
}
|
||||
|
||||
// If len is specified, then do not read more than len bytes.
|
||||
if let Some(len) = self.len {
|
||||
if self.next_atom_pos - self.base_pos >= len {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the next atom header.
|
||||
let atom = AtomHeader::read(&mut self.reader)?;
|
||||
|
||||
// Calculate the start position for the next atom (the exclusive end of the current atom).
|
||||
self.next_atom_pos = match atom.atom_len {
|
||||
0 => {
|
||||
// An atom with a length of zero is defined to span to the end of the stream. If
|
||||
// len is available, use it for the next atom start position, otherwise, use u64 max
|
||||
// which will trip an end of stream error on the next iteration.
|
||||
self.len.map(|l| self.base_pos + l).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
len => self.next_atom_pos + len,
|
||||
};
|
||||
|
||||
self.cur_atom = Some(atom);
|
||||
|
||||
Ok(self.cur_atom)
|
||||
}
|
||||
|
||||
pub fn next_no_consume(&mut self) -> Result<Option<AtomHeader>> {
|
||||
if self.cur_atom.is_some() {
|
||||
Ok(self.cur_atom)
|
||||
}
|
||||
else {
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_atom<A: Atom>(&mut self) -> Result<A> {
|
||||
// It is not possible to read the current atom more than once because ByteStream is not
|
||||
// seekable. Therefore, raise an assert if read_atom is called more than once between calls
|
||||
// to next, or after next returns None.
|
||||
assert!(self.cur_atom.is_some());
|
||||
A::read(&mut self.reader, self.cur_atom.take().unwrap())
|
||||
}
|
||||
|
||||
pub fn consume_atom(&mut self) {
|
||||
assert!(self.cur_atom.take().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MfhdAtom, TrafAtom};
|
||||
|
||||
/// Movie fragment atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MoofAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// The position of the first byte of this moof atom. This is used as the anchor point for the
|
||||
/// subsequent track atoms.
|
||||
pub moof_base_pos: u64,
|
||||
/// Movie fragment header.
|
||||
pub mfhd: MfhdAtom,
|
||||
/// Track fragments.
|
||||
pub trafs: Vec<TrafAtom>,
|
||||
}
|
||||
|
||||
impl Atom for MoofAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let moof_base_pos = reader.pos() - AtomHeader::HEADER_SIZE;
|
||||
|
||||
let mut mfhd = None;
|
||||
let mut trafs = Vec::new();
|
||||
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MovieFragmentHeader => {
|
||||
mfhd = Some(iter.read_atom::<MfhdAtom>()?);
|
||||
}
|
||||
AtomType::TrackFragment => {
|
||||
let traf = iter.read_atom::<TrafAtom>()?;
|
||||
trafs.push(traf);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if mfhd.is_none() {
|
||||
return decode_error("isomp4: missing mfhd atom");
|
||||
}
|
||||
|
||||
Ok(MoofAtom { header, moof_base_pos, mfhd: mfhd.unwrap(), trafs })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
use symphonia_core::meta::MetadataRevision;
|
||||
|
||||
use crate::atoms::{
|
||||
Atom, AtomHeader, AtomIterator, AtomType, MvexAtom, MvhdAtom, TrakAtom, UdtaAtom,
|
||||
};
|
||||
|
||||
use log::warn;
|
||||
|
||||
/// Movie atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MoovAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Movie header atom.
|
||||
pub mvhd: MvhdAtom,
|
||||
/// Trak atoms.
|
||||
pub traks: Vec<TrakAtom>,
|
||||
/// Movie extends atom. The presence of this atom indicates a fragmented stream.
|
||||
pub mvex: Option<MvexAtom>,
|
||||
/// User data (usually metadata).
|
||||
pub udta: Option<UdtaAtom>,
|
||||
}
|
||||
|
||||
impl MoovAtom {
|
||||
/// If metadata was read, consumes the metadata and returns it.
|
||||
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
|
||||
self.udta.as_mut().and_then(|udta| udta.take_metadata())
|
||||
}
|
||||
|
||||
/// Is the movie segmented.
|
||||
pub fn is_fragmented(&self) -> bool {
|
||||
self.mvex.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for MoovAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut mvhd = None;
|
||||
let mut traks = Vec::new();
|
||||
let mut mvex = None;
|
||||
let mut udta = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MovieHeader => {
|
||||
mvhd = Some(iter.read_atom::<MvhdAtom>()?);
|
||||
}
|
||||
AtomType::Track => {
|
||||
// Gracefully skip malformed tracks (e.g. MJPEG cover-art streams
|
||||
// in older iTunes M4A files whose stbl or stsd may be missing
|
||||
// required atoms). The AtomIterator's next_atom_pos was already
|
||||
// set before read_atom was called, so a failure mid-parse still
|
||||
// leaves the iterator positioned at the end of this atom.
|
||||
match iter.read_atom::<TrakAtom>() {
|
||||
Ok(trak) => traks.push(trak),
|
||||
Err(e) => warn!("isomp4: skipping malformed trak atom: {}", e),
|
||||
}
|
||||
}
|
||||
AtomType::MovieExtends => {
|
||||
mvex = Some(iter.read_atom::<MvexAtom>()?);
|
||||
}
|
||||
AtomType::UserData => {
|
||||
udta = Some(iter.read_atom::<UdtaAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if mvhd.is_none() {
|
||||
return decode_error("isomp4: missing mvhd atom");
|
||||
}
|
||||
|
||||
// If fragmented, the mvex atom should contain a trex atom for each trak atom in moov.
|
||||
if let Some(mvex) = mvex.as_ref() {
|
||||
// For each trak, find a matching trex atom using the track id.
|
||||
for trak in traks.iter() {
|
||||
let found = mvex.trexs.iter().any(|trex| trex.track_id == trak.tkhd.id);
|
||||
|
||||
if !found {
|
||||
warn!("missing trex atom for trak with id={}", trak.tkhd.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MoovAtom { header, mvhd: mvhd.unwrap(), traks, mvex, udta })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MehdAtom, TrexAtom};
|
||||
|
||||
/// Movie extends atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MvexAtom {
|
||||
/// Atom header.
|
||||
pub header: AtomHeader,
|
||||
/// Movie extends header, optional.
|
||||
pub mehd: Option<MehdAtom>,
|
||||
/// Track extends box, one per track.
|
||||
pub trexs: Vec<TrexAtom>,
|
||||
}
|
||||
|
||||
impl Atom for MvexAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut mehd = None;
|
||||
let mut trexs = Vec::new();
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::MovieExtendsHeader => {
|
||||
mehd = Some(iter.read_atom::<MehdAtom>()?);
|
||||
}
|
||||
AtomType::TrackExtends => {
|
||||
let trex = iter.read_atom::<TrexAtom>()?;
|
||||
trexs.push(trex);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MvexAtom { header, mehd, trexs })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
use crate::fp::FpU8;
|
||||
|
||||
/// Movie header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct MvhdAtom {
|
||||
/// Atom header.
|
||||
pub header: AtomHeader,
|
||||
/// The creation time.
|
||||
pub ctime: u64,
|
||||
/// The modification time.
|
||||
pub mtime: u64,
|
||||
/// Timescale for the movie expressed as the number of units per second.
|
||||
pub timescale: u32,
|
||||
/// The duration of the movie in `timescale` units.
|
||||
pub duration: u64,
|
||||
/// The preferred volume to play the movie.
|
||||
pub volume: FpU8,
|
||||
}
|
||||
|
||||
impl Atom for MvhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let mut mvhd = MvhdAtom {
|
||||
header,
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
timescale: 0,
|
||||
duration: 0,
|
||||
volume: Default::default(),
|
||||
};
|
||||
|
||||
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
|
||||
match version {
|
||||
0 => {
|
||||
mvhd.ctime = u64::from(reader.read_be_u32()?);
|
||||
mvhd.mtime = u64::from(reader.read_be_u32()?);
|
||||
mvhd.timescale = reader.read_be_u32()?;
|
||||
// 0xffff_ffff is a special case.
|
||||
mvhd.duration = match reader.read_be_u32()? {
|
||||
u32::MAX => u64::MAX,
|
||||
duration => u64::from(duration),
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
mvhd.ctime = reader.read_be_u64()?;
|
||||
mvhd.mtime = reader.read_be_u64()?;
|
||||
mvhd.timescale = reader.read_be_u32()?;
|
||||
mvhd.duration = reader.read_be_u64()?;
|
||||
}
|
||||
_ => return decode_error("isomp4: invalid mvhd version"),
|
||||
}
|
||||
|
||||
// Ignore the preferred playback rate.
|
||||
let _ = reader.read_be_u32()?;
|
||||
|
||||
// Preferred volume.
|
||||
mvhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
|
||||
|
||||
// Remaining fields are ignored.
|
||||
|
||||
Ok(mvhd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_OPUS};
|
||||
use symphonia_core::errors::{decode_error, unsupported_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct OpusAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Opus extra data (identification header).
|
||||
extra_data: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl Atom for OpusAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
const OPUS_MAGIC: &[u8] = b"OpusHead";
|
||||
const OPUS_MAGIC_LEN: usize = OPUS_MAGIC.len();
|
||||
|
||||
const MIN_OPUS_EXTRA_DATA_SIZE: usize = OPUS_MAGIC_LEN + 11;
|
||||
const MAX_OPUS_EXTRA_DATA_SIZE: usize = MIN_OPUS_EXTRA_DATA_SIZE + 257;
|
||||
|
||||
// Offset of the Opus version number in the extra data.
|
||||
const OPUS_EXTRADATA_VERSION_OFFSET: usize = OPUS_MAGIC_LEN;
|
||||
|
||||
// The dops atom contains an Opus identification header excluding the OpusHead magic
|
||||
// signature. Therefore, the atom data length should be atleast as long as the shortest
|
||||
// Opus identification header.
|
||||
let data_len = header.data_len as usize;
|
||||
|
||||
if data_len < MIN_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
|
||||
return decode_error("isomp4 (opus): opus identification header too short");
|
||||
}
|
||||
|
||||
if data_len > MAX_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
|
||||
return decode_error("isomp4 (opus): opus identification header too large");
|
||||
}
|
||||
|
||||
let mut extra_data = vec![0; OPUS_MAGIC_LEN + data_len].into_boxed_slice();
|
||||
|
||||
// The Opus magic is excluded in the atom, but the extra data must start with it.
|
||||
extra_data[..OPUS_MAGIC_LEN].copy_from_slice(OPUS_MAGIC);
|
||||
|
||||
// Read the extra data from the atom.
|
||||
reader.read_buf_exact(&mut extra_data[OPUS_MAGIC_LEN..])?;
|
||||
|
||||
// Verify the version number is 0.
|
||||
if extra_data[OPUS_EXTRADATA_VERSION_OFFSET] != 0 {
|
||||
return unsupported_error("isomp4 (opus): unsupported opus version");
|
||||
}
|
||||
|
||||
Ok(OpusAtom { header, extra_data })
|
||||
}
|
||||
}
|
||||
|
||||
impl OpusAtom {
|
||||
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
|
||||
codec_params.for_codec(CODEC_TYPE_OPUS).with_extra_data(self.extra_data.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ReferenceType {
|
||||
Segment,
|
||||
Media,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct SidxReference {
|
||||
pub reference_type: ReferenceType,
|
||||
pub reference_size: u32,
|
||||
pub subsegment_duration: u32,
|
||||
// pub starts_with_sap: bool,
|
||||
// pub sap_type: u8,
|
||||
// pub sap_delta_time: u32,
|
||||
}
|
||||
|
||||
/// Segment index atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct SidxAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub reference_id: u32,
|
||||
pub timescale: u32,
|
||||
pub earliest_pts: u64,
|
||||
pub first_offset: u64,
|
||||
pub references: Vec<SidxReference>,
|
||||
}
|
||||
|
||||
impl Atom for SidxAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
// The anchor point for segment offsets is the first byte after this atom.
|
||||
let anchor = reader.pos() + header.data_len;
|
||||
|
||||
let (version, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let reference_id = reader.read_be_u32()?;
|
||||
let timescale = reader.read_be_u32()?;
|
||||
|
||||
let (earliest_pts, first_offset) = match version {
|
||||
0 => (u64::from(reader.read_be_u32()?), anchor + u64::from(reader.read_be_u32()?)),
|
||||
1 => (reader.read_be_u64()?, anchor + reader.read_be_u64()?),
|
||||
_ => {
|
||||
return decode_error("isomp4: invalid sidx version");
|
||||
}
|
||||
};
|
||||
|
||||
let _reserved = reader.read_be_u16()?;
|
||||
let reference_count = reader.read_be_u16()?;
|
||||
|
||||
let mut references = Vec::new();
|
||||
|
||||
for _ in 0..reference_count {
|
||||
let reference = reader.read_be_u32()?;
|
||||
let subsegment_duration = reader.read_be_u32()?;
|
||||
|
||||
let reference_type = match (reference & 0x8000_0000) != 0 {
|
||||
false => ReferenceType::Media,
|
||||
true => ReferenceType::Segment,
|
||||
};
|
||||
|
||||
let reference_size = reference & !0x8000_0000;
|
||||
|
||||
// Ignore SAP
|
||||
let _ = reader.read_be_u32()?;
|
||||
|
||||
references.push(SidxReference { reference_type, reference_size, subsegment_duration });
|
||||
}
|
||||
|
||||
Ok(SidxAtom { header, reference_id, timescale, earliest_pts, first_offset, references })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
use crate::fp::FpI8;
|
||||
|
||||
/// Sound header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct SmhdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Stereo balance.
|
||||
pub balance: FpI8,
|
||||
}
|
||||
|
||||
impl Atom for SmhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
// Stereo balance
|
||||
let balance = FpI8::parse_raw(reader.read_be_u16()? as i16);
|
||||
|
||||
// Reserved.
|
||||
let _ = reader.read_be_u16()?;
|
||||
|
||||
Ok(SmhdAtom { header, balance })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
|
||||
use crate::atoms::{Co64Atom, StcoAtom, StscAtom, StsdAtom, StszAtom, SttsAtom};
|
||||
|
||||
use log::warn;
|
||||
|
||||
/// Sample table atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StblAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub stsd: StsdAtom,
|
||||
pub stts: SttsAtom,
|
||||
pub stsc: StscAtom,
|
||||
pub stsz: StszAtom,
|
||||
pub stco: Option<StcoAtom>,
|
||||
pub co64: Option<Co64Atom>,
|
||||
}
|
||||
|
||||
impl Atom for StblAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut stsd = None;
|
||||
let mut stts = None;
|
||||
let mut stsc = None;
|
||||
let mut stsz = None;
|
||||
let mut stco = None;
|
||||
let mut co64 = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::SampleDescription => {
|
||||
stsd = Some(iter.read_atom::<StsdAtom>()?);
|
||||
}
|
||||
AtomType::TimeToSample => {
|
||||
stts = Some(iter.read_atom::<SttsAtom>()?);
|
||||
}
|
||||
AtomType::CompositionTimeToSample => {
|
||||
// Composition time to sample atom is only required for video.
|
||||
warn!("ignoring ctts atom.");
|
||||
}
|
||||
AtomType::SyncSample => {
|
||||
// Sync sample atom is only required for video.
|
||||
warn!("ignoring stss atom.");
|
||||
}
|
||||
AtomType::SampleToChunk => {
|
||||
stsc = Some(iter.read_atom::<StscAtom>()?);
|
||||
}
|
||||
AtomType::SampleSize => {
|
||||
stsz = Some(iter.read_atom::<StszAtom>()?);
|
||||
}
|
||||
AtomType::ChunkOffset => {
|
||||
stco = Some(iter.read_atom::<StcoAtom>()?);
|
||||
}
|
||||
AtomType::ChunkOffset64 => {
|
||||
co64 = Some(iter.read_atom::<Co64Atom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if stsd.is_none() {
|
||||
return decode_error("isomp4: missing stsd atom");
|
||||
}
|
||||
|
||||
if stts.is_none() {
|
||||
return decode_error("isomp4: missing stts atom");
|
||||
}
|
||||
|
||||
if stsc.is_none() {
|
||||
return decode_error("isomp4: missing stsc atom");
|
||||
}
|
||||
|
||||
if stsz.is_none() {
|
||||
return decode_error("isomp4: missing stsz atom");
|
||||
}
|
||||
|
||||
if stco.is_none() && co64.is_none() {
|
||||
// This is a spec. violation, but some m4a files appear to lack these atoms.
|
||||
warn!("missing stco or co64 atom");
|
||||
}
|
||||
|
||||
Ok(StblAtom {
|
||||
header,
|
||||
stsd: stsd.unwrap(),
|
||||
stts: stts.unwrap(),
|
||||
stsc: stsc.unwrap(),
|
||||
stsz: stsz.unwrap(),
|
||||
stco,
|
||||
co64,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Chunk offset atom (32-bit version).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StcoAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub chunk_offsets: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Atom for StcoAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let entry_count = reader.read_be_u32()?;
|
||||
|
||||
// TODO: Apply a limit.
|
||||
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
|
||||
|
||||
for _ in 0..entry_count {
|
||||
chunk_offsets.push(reader.read_be_u32()?);
|
||||
}
|
||||
|
||||
Ok(StcoAtom { header, chunk_offsets })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StscEntry {
|
||||
pub first_chunk: u32,
|
||||
pub first_sample: u32,
|
||||
pub samples_per_chunk: u32,
|
||||
pub sample_desc_index: u32,
|
||||
}
|
||||
|
||||
/// Sample to Chunk Atom
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StscAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Entries.
|
||||
pub entries: Vec<StscEntry>,
|
||||
}
|
||||
|
||||
impl StscAtom {
|
||||
/// Finds the `StscEntry` for the sample indicated by `sample_num`. Note, `sample_num` is indexed
|
||||
/// relative to the `StscAtom`. Complexity is O(log2 N).
|
||||
pub fn find_entry_for_sample(&self, sample_num: u32) -> Option<&StscEntry> {
|
||||
let mut left = 1;
|
||||
let mut right = self.entries.len();
|
||||
|
||||
while left < right {
|
||||
let mid = left + (right - left) / 2;
|
||||
|
||||
let entry = self.entries.get(mid).unwrap();
|
||||
|
||||
if entry.first_sample < sample_num {
|
||||
left = mid + 1;
|
||||
}
|
||||
else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
// The index found above (left) is the exclusive upper bound of all entries where
|
||||
// first_sample < sample_num. Therefore, the entry to return has an index of left-1. The
|
||||
// index will never equal 0 so this is safe. If the table were empty, left == 1, thus calling
|
||||
// get with an index of 0, and safely returning None.
|
||||
self.entries.get(left - 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for StscAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let entry_count = reader.read_be_u32()?;
|
||||
|
||||
// TODO: Apply a limit.
|
||||
let mut entries = Vec::with_capacity(entry_count as usize);
|
||||
|
||||
for _ in 0..entry_count {
|
||||
entries.push(StscEntry {
|
||||
first_chunk: reader.read_be_u32()? - 1,
|
||||
first_sample: 0,
|
||||
samples_per_chunk: reader.read_be_u32()?,
|
||||
sample_desc_index: reader.read_be_u32()?,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-process entries to check for errors and calculate the file sample.
|
||||
if entry_count > 0 {
|
||||
for i in 0..entry_count as usize - 1 {
|
||||
// Validate that first_chunk is monotonic across all entries.
|
||||
if entries[i + 1].first_chunk < entries[i].first_chunk {
|
||||
return decode_error("isomp4: stsc entry first chunk not monotonic");
|
||||
}
|
||||
|
||||
// Validate that samples per chunk is > 0. Could the entry be ignored?
|
||||
if entries[i].samples_per_chunk == 0 {
|
||||
return decode_error("isomp4: stsc entry has 0 samples per chunk");
|
||||
}
|
||||
|
||||
let n = entries[i + 1].first_chunk - entries[i].first_chunk;
|
||||
|
||||
entries[i + 1].first_sample =
|
||||
entries[i].first_sample + (n * entries[i].samples_per_chunk);
|
||||
}
|
||||
|
||||
// Validate that samples per chunk is > 0. Could the entry be ignored?
|
||||
if entries[entry_count as usize - 1].samples_per_chunk == 0 {
|
||||
return decode_error("isomp4: stsc entry has 0 samples per chunk");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StscAtom { header, entries })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::audio::Channels;
|
||||
use symphonia_core::codecs::{CodecParameters, CodecType, CODEC_TYPE_MP3, CODEC_TYPE_NULL};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_S16BE, CODEC_TYPE_PCM_S16LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_S24BE, CODEC_TYPE_PCM_S24LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_S32BE, CODEC_TYPE_PCM_S32LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_S8, CODEC_TYPE_PCM_U8};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_U16BE, CODEC_TYPE_PCM_U16LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_U24BE, CODEC_TYPE_PCM_U24LE};
|
||||
use symphonia_core::codecs::{CODEC_TYPE_PCM_U32BE, CODEC_TYPE_PCM_U32LE};
|
||||
use symphonia_core::errors::{decode_error, unsupported_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{AlacAtom, Atom, AtomHeader, AtomType, EsdsAtom, FlacAtom, OpusAtom, WaveAtom};
|
||||
use crate::fp::FpU16;
|
||||
|
||||
use super::AtomIterator;
|
||||
|
||||
/// Sample description atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StsdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Sample entry.
|
||||
sample_entry: SampleEntry,
|
||||
}
|
||||
|
||||
impl Atom for StsdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let n_entries = reader.read_be_u32()?;
|
||||
|
||||
if n_entries == 0 {
|
||||
return decode_error("isomp4: missing sample entry");
|
||||
}
|
||||
|
||||
if n_entries > 1 {
|
||||
return unsupported_error("isomp4: more than 1 sample entry");
|
||||
}
|
||||
|
||||
let sample_entry_header = AtomHeader::read(reader)?;
|
||||
|
||||
let sample_entry = match sample_entry_header.atype {
|
||||
AtomType::Mp4a
|
||||
| AtomType::Alac
|
||||
| AtomType::Flac
|
||||
| AtomType::Opus
|
||||
| AtomType::Mp3
|
||||
| AtomType::Lpcm
|
||||
| AtomType::QtWave
|
||||
| AtomType::ALaw
|
||||
| AtomType::MuLaw
|
||||
| AtomType::U8SampleEntry
|
||||
| AtomType::S16LeSampleEntry
|
||||
| AtomType::S16BeSampleEntry
|
||||
| AtomType::S24SampleEntry
|
||||
| AtomType::S32SampleEntry
|
||||
| AtomType::F32SampleEntry
|
||||
| AtomType::F64SampleEntry => read_audio_sample_entry(reader, sample_entry_header)?,
|
||||
_ => {
|
||||
// Potentially video, subtitles, etc.
|
||||
SampleEntry::Other
|
||||
}
|
||||
};
|
||||
|
||||
Ok(StsdAtom { header, sample_entry })
|
||||
}
|
||||
}
|
||||
|
||||
impl StsdAtom {
|
||||
/// Fill the provided `CodecParameters` using the sample entry.
|
||||
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
|
||||
// Audio sample entry.
|
||||
if let SampleEntry::Audio(ref entry) = self.sample_entry {
|
||||
// General audio parameters.
|
||||
codec_params.with_sample_rate(entry.sample_rate as u32);
|
||||
|
||||
// Codec-specific parameters.
|
||||
match entry.codec_specific {
|
||||
Some(AudioCodecSpecific::Esds(ref esds)) => {
|
||||
esds.fill_codec_params(codec_params);
|
||||
}
|
||||
Some(AudioCodecSpecific::Alac(ref alac)) => {
|
||||
alac.fill_codec_params(codec_params);
|
||||
}
|
||||
Some(AudioCodecSpecific::Flac(ref flac)) => {
|
||||
flac.fill_codec_params(codec_params);
|
||||
}
|
||||
Some(AudioCodecSpecific::Opus(ref opus)) => {
|
||||
opus.fill_codec_params(codec_params);
|
||||
}
|
||||
Some(AudioCodecSpecific::Mp3) => {
|
||||
codec_params.for_codec(CODEC_TYPE_MP3);
|
||||
}
|
||||
Some(AudioCodecSpecific::Pcm(ref pcm)) => {
|
||||
// PCM codecs.
|
||||
codec_params
|
||||
.for_codec(pcm.codec_type)
|
||||
.with_bits_per_coded_sample(pcm.bits_per_coded_sample)
|
||||
.with_bits_per_sample(pcm.bits_per_sample)
|
||||
.with_max_frames_per_packet(pcm.frames_per_packet)
|
||||
.with_channels(pcm.channels);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Pcm {
|
||||
pub codec_type: CodecType,
|
||||
pub bits_per_sample: u32,
|
||||
pub bits_per_coded_sample: u32,
|
||||
pub frames_per_packet: u64,
|
||||
pub channels: Channels,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AudioCodecSpecific {
|
||||
/// MPEG Elementary Stream descriptor.
|
||||
Esds(EsdsAtom),
|
||||
/// Apple Lossless Audio Codec (ALAC).
|
||||
Alac(AlacAtom),
|
||||
/// Free Lossless Audio Codec (FLAC).
|
||||
Flac(FlacAtom),
|
||||
/// Opus.
|
||||
Opus(OpusAtom),
|
||||
/// MP3.
|
||||
Mp3,
|
||||
/// PCM codecs.
|
||||
Pcm(Pcm),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct AudioSampleEntry {
|
||||
pub num_channels: u32,
|
||||
pub sample_size: u16,
|
||||
pub sample_rate: f64,
|
||||
pub codec_specific: Option<AudioCodecSpecific>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SampleEntry {
|
||||
Audio(AudioSampleEntry),
|
||||
// Video,
|
||||
// Metadata,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Gets if the sample entry atom is for a PCM codec.
|
||||
fn is_pcm_codec(atype: AtomType) -> bool {
|
||||
// PCM data in version 0 and 1 is signalled by the sample entry atom type. In version 2, the
|
||||
// atom type for PCM data is always LPCM.
|
||||
atype == AtomType::Lpcm || pcm_codec_type(atype) != CODEC_TYPE_NULL
|
||||
}
|
||||
|
||||
/// Gets the PCM codec from the sample entry atom type for version 0 and 1 sample entries.
|
||||
fn pcm_codec_type(atype: AtomType) -> CodecType {
|
||||
match atype {
|
||||
AtomType::U8SampleEntry => CODEC_TYPE_PCM_U8,
|
||||
AtomType::S16LeSampleEntry => CODEC_TYPE_PCM_S16LE,
|
||||
AtomType::S16BeSampleEntry => CODEC_TYPE_PCM_S16BE,
|
||||
AtomType::S24SampleEntry => CODEC_TYPE_PCM_S24LE,
|
||||
AtomType::S32SampleEntry => CODEC_TYPE_PCM_S32LE,
|
||||
AtomType::F32SampleEntry => CODEC_TYPE_PCM_F32LE,
|
||||
AtomType::F64SampleEntry => CODEC_TYPE_PCM_F64LE,
|
||||
_ => CODEC_TYPE_NULL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the number of bytes per PCM sample for a PCM codec type.
|
||||
fn bytes_per_pcm_sample(pcm_codec_type: CodecType) -> u32 {
|
||||
match pcm_codec_type {
|
||||
CODEC_TYPE_PCM_S8 | CODEC_TYPE_PCM_U8 => 1,
|
||||
CODEC_TYPE_PCM_S16BE | CODEC_TYPE_PCM_S16LE => 2,
|
||||
CODEC_TYPE_PCM_U16BE | CODEC_TYPE_PCM_U16LE => 2,
|
||||
CODEC_TYPE_PCM_S24BE | CODEC_TYPE_PCM_S24LE => 3,
|
||||
CODEC_TYPE_PCM_U24BE | CODEC_TYPE_PCM_U24LE => 3,
|
||||
CODEC_TYPE_PCM_S32BE | CODEC_TYPE_PCM_S32LE => 4,
|
||||
CODEC_TYPE_PCM_U32BE | CODEC_TYPE_PCM_U32LE => 4,
|
||||
CODEC_TYPE_PCM_F32BE | CODEC_TYPE_PCM_F32LE => 4,
|
||||
CODEC_TYPE_PCM_F64BE | CODEC_TYPE_PCM_F64LE => 8,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the PCM codec from the LPCM parameters in the version 2 sample entry atom.
|
||||
fn lpcm_codec_type(bits_per_sample: u32, lpcm_flags: u32) -> CodecType {
|
||||
let is_floating_point = lpcm_flags & 0x1 != 0;
|
||||
let is_big_endian = lpcm_flags & 0x2 != 0;
|
||||
let is_signed = lpcm_flags & 0x4 != 0;
|
||||
|
||||
if is_floating_point {
|
||||
// Floating-point sample format.
|
||||
match bits_per_sample {
|
||||
32 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_F32BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_F32LE
|
||||
}
|
||||
}
|
||||
64 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_F64BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_F64LE
|
||||
}
|
||||
}
|
||||
_ => CODEC_TYPE_NULL,
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Integer sample format.
|
||||
if is_signed {
|
||||
// Signed-integer sample format.
|
||||
match bits_per_sample {
|
||||
8 => CODEC_TYPE_PCM_S8,
|
||||
16 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_S16BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_S16LE
|
||||
}
|
||||
}
|
||||
24 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_S24BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_S24LE
|
||||
}
|
||||
}
|
||||
32 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_S32BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_S32LE
|
||||
}
|
||||
}
|
||||
_ => CODEC_TYPE_NULL,
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Unsigned-integer sample format.
|
||||
match bits_per_sample {
|
||||
8 => CODEC_TYPE_PCM_U8,
|
||||
16 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_U16BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_U16LE
|
||||
}
|
||||
}
|
||||
24 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_U24BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_U24LE
|
||||
}
|
||||
}
|
||||
32 => {
|
||||
if is_big_endian {
|
||||
CODEC_TYPE_PCM_U32BE
|
||||
}
|
||||
else {
|
||||
CODEC_TYPE_PCM_U32LE
|
||||
}
|
||||
}
|
||||
_ => CODEC_TYPE_NULL,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the audio channels for a version 0 or 1 sample entry.
|
||||
fn pcm_channels(num_channels: u32) -> Result<Channels> {
|
||||
match num_channels {
|
||||
1 => Ok(Channels::FRONT_LEFT),
|
||||
2 => Ok(Channels::FRONT_LEFT | Channels::FRONT_RIGHT),
|
||||
_ => decode_error("isomp4: invalid number of channels"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the audio channels for a version 2 LPCM sample entry.
|
||||
fn lpcm_channels(num_channels: u32) -> Result<Channels> {
|
||||
if num_channels < 1 {
|
||||
return decode_error("isomp4: invalid number of channels");
|
||||
}
|
||||
|
||||
if num_channels > 32 {
|
||||
return unsupported_error("isomp4: maximum 32 channels");
|
||||
}
|
||||
|
||||
// TODO: For LPCM, the channels are "auxilary". They do not have a speaker assignment. Symphonia
|
||||
// does not have a way to represent this yet.
|
||||
let channel_mask = !((!0 << 1) << (num_channels - 1));
|
||||
|
||||
match Channels::from_bits(channel_mask) {
|
||||
Some(channels) => Ok(channels),
|
||||
_ => unsupported_error("isomp4: unsupported number of channels"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_audio_sample_entry<B: ReadBytes>(
|
||||
reader: &mut B,
|
||||
mut header: AtomHeader,
|
||||
) -> Result<SampleEntry> {
|
||||
// An audio sample entry atom is derived from a base sample entry atom. The audio sample entry
|
||||
// atom contains the fields of the base sample entry first, then the audio sample entry fields
|
||||
// next. After those fields, a number of other atoms are nested, including the mandatory
|
||||
// codec-specific atom. Though the codec-specific atom is nested within the (audio) sample entry
|
||||
// atom, the (audio) sample entry atom uses the atom type of the codec-specific atom. This is
|
||||
// odd in-that the final structure will appear to have the codec-specific atom nested within
|
||||
// itself, which is not actually the case.
|
||||
|
||||
let data_start_pos = reader.pos();
|
||||
|
||||
// First 6 bytes of all sample entries should be all 0.
|
||||
reader.ignore_bytes(6)?;
|
||||
|
||||
// Sample entry data reference.
|
||||
let _ = reader.read_be_u16()?;
|
||||
|
||||
// The version of the audio sample entry.
|
||||
let version = reader.read_be_u16()?;
|
||||
|
||||
// Skip revision and vendor.
|
||||
reader.ignore_bytes(6)?;
|
||||
|
||||
let mut num_channels = u32::from(reader.read_be_u16()?);
|
||||
let sample_size = reader.read_be_u16()?;
|
||||
|
||||
// Skip compression ID and packet size.
|
||||
reader.ignore_bytes(4)?;
|
||||
|
||||
let mut sample_rate = f64::from(FpU16::parse_raw(reader.read_be_u32()?));
|
||||
|
||||
let is_pcm_codec = is_pcm_codec(header.atype);
|
||||
|
||||
let mut codec_specific = match version {
|
||||
0 => {
|
||||
// Version 0.
|
||||
if is_pcm_codec {
|
||||
let codec_type = pcm_codec_type(header.atype);
|
||||
let bits_per_sample = 8 * bytes_per_pcm_sample(codec_type);
|
||||
|
||||
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
|
||||
if u32::from(sample_size) != bits_per_sample {
|
||||
return decode_error("isomp4: invalid pcm sample size");
|
||||
}
|
||||
|
||||
// The original fields describe the PCM sample format.
|
||||
Some(AudioCodecSpecific::Pcm(Pcm {
|
||||
codec_type: pcm_codec_type(header.atype),
|
||||
bits_per_sample,
|
||||
bits_per_coded_sample: bits_per_sample,
|
||||
frames_per_packet: 1,
|
||||
channels: pcm_channels(num_channels)?,
|
||||
}))
|
||||
}
|
||||
else {
|
||||
None
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Version 1.
|
||||
|
||||
// The number of frames (ISO/MP4 samples) per packet. For PCM codecs, this is always 1.
|
||||
let _frames_per_packet = reader.read_be_u32()?;
|
||||
|
||||
// The number of bytes per PCM audio sample. This value supersedes sample_size. For
|
||||
// non-PCM codecs, this value is not useful.
|
||||
let bytes_per_audio_sample = reader.read_be_u32()?;
|
||||
|
||||
// The number of bytes per PCM audio frame (ISO/MP4 sample). For non-PCM codecs, this
|
||||
// value is not useful.
|
||||
let _bytes_per_frame = reader.read_be_u32()?;
|
||||
|
||||
// The next value, as defined, is seemingly non-sensical.
|
||||
let _ = reader.read_be_u32()?;
|
||||
|
||||
if is_pcm_codec {
|
||||
let codec_type = pcm_codec_type(header.atype);
|
||||
let codec_bytes_per_sample = bytes_per_pcm_sample(codec_type);
|
||||
|
||||
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
|
||||
if bytes_per_audio_sample != codec_bytes_per_sample {
|
||||
return decode_error("isomp4: invalid pcm bytes per sample");
|
||||
}
|
||||
|
||||
// The new fields describe the PCM sample format and supersede the original version
|
||||
// 0 fields.
|
||||
Some(AudioCodecSpecific::Pcm(Pcm {
|
||||
codec_type,
|
||||
bits_per_sample: 8 * codec_bytes_per_sample,
|
||||
bits_per_coded_sample: 8 * codec_bytes_per_sample,
|
||||
frames_per_packet: 1,
|
||||
channels: pcm_channels(num_channels)?,
|
||||
}))
|
||||
}
|
||||
else {
|
||||
None
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Version 2.
|
||||
reader.ignore_bytes(4)?;
|
||||
|
||||
sample_rate = reader.read_be_f64()?;
|
||||
num_channels = reader.read_be_u32()?;
|
||||
|
||||
if reader.read_be_u32()? != 0x7f00_0000 {
|
||||
return decode_error("isomp4: audio sample entry v2 reserved must be 0x7f00_0000");
|
||||
}
|
||||
|
||||
// The following fields are only useful for PCM codecs.
|
||||
let bits_per_sample = reader.read_be_u32()?;
|
||||
let lpcm_flags = reader.read_be_u32()?;
|
||||
let _bytes_per_packet = reader.read_be_u32()?;
|
||||
let lpcm_frames_per_packet = reader.read_be_u32()?;
|
||||
|
||||
// This is only valid if this is a PCM codec.
|
||||
let codec_type = lpcm_codec_type(bits_per_sample, lpcm_flags);
|
||||
|
||||
if is_pcm_codec && codec_type != CODEC_TYPE_NULL {
|
||||
// Like version 1, the new fields describe the PCM sample format and supersede the
|
||||
// original version 0 fields.
|
||||
Some(AudioCodecSpecific::Pcm(Pcm {
|
||||
codec_type,
|
||||
bits_per_sample,
|
||||
bits_per_coded_sample: bits_per_sample,
|
||||
frames_per_packet: u64::from(lpcm_frames_per_packet),
|
||||
channels: lpcm_channels(num_channels)?,
|
||||
}))
|
||||
}
|
||||
else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return unsupported_error("isomp4: unknown sample entry version");
|
||||
}
|
||||
};
|
||||
|
||||
// Need to account for the data already read from the atom.
|
||||
header.data_len -= reader.pos() - data_start_pos;
|
||||
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
while let Some(entry_header) = iter.next()? {
|
||||
match entry_header.atype {
|
||||
AtomType::Esds => {
|
||||
// MP4A/ESDS codec-specific atom.
|
||||
if header.atype != AtomType::Mp4a || codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Esds(iter.read_atom::<EsdsAtom>()?));
|
||||
}
|
||||
AtomType::Alac => {
|
||||
// ALAC codec-specific atom.
|
||||
if header.atype != AtomType::Alac || codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Alac(iter.read_atom::<AlacAtom>()?));
|
||||
}
|
||||
AtomType::FlacDsConfig => {
|
||||
// FLAC codec-specific atom.
|
||||
if header.atype != AtomType::Flac || codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Flac(iter.read_atom::<FlacAtom>()?));
|
||||
}
|
||||
AtomType::OpusDsConfig => {
|
||||
// Opus codec-specific atom.
|
||||
if header.atype != AtomType::Opus || codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Opus(iter.read_atom::<OpusAtom>()?));
|
||||
}
|
||||
AtomType::QtWave => {
|
||||
// The QuickTime WAVE (aka. siDecompressionParam) atom may contain many different
|
||||
// types of sub-atoms to store decoder parameters.
|
||||
let wave = iter.read_atom::<WaveAtom>()?;
|
||||
|
||||
if let Some(esds) = wave.esds {
|
||||
if codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Esds(esds));
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
// A MP3 sample entry has no codec-specific atom.
|
||||
if header.atype == AtomType::Mp3 {
|
||||
if codec_specific.is_some() {
|
||||
return decode_error("isomp4: invalid sample entry");
|
||||
}
|
||||
|
||||
codec_specific = Some(AudioCodecSpecific::Mp3);
|
||||
}
|
||||
|
||||
Ok(SampleEntry::Audio(AudioSampleEntry {
|
||||
num_channels,
|
||||
sample_size,
|
||||
sample_rate,
|
||||
codec_specific,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StssAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
}
|
||||
|
||||
impl Atom for StssAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SampleSize {
|
||||
Constant(u32),
|
||||
Variable(Vec<u32>),
|
||||
}
|
||||
|
||||
/// Sample Size Atom
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StszAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// The total number of samples.
|
||||
pub sample_count: u32,
|
||||
/// A vector of `sample_count` sample sizes, or a constant size for all samples.
|
||||
pub sample_sizes: SampleSize,
|
||||
}
|
||||
|
||||
impl Atom for StszAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let sample_size = reader.read_be_u32()?;
|
||||
let sample_count = reader.read_be_u32()?;
|
||||
|
||||
let sample_sizes = if sample_size == 0 {
|
||||
// TODO: Apply a limit.
|
||||
let mut entries = Vec::with_capacity(sample_count as usize);
|
||||
|
||||
for _ in 0..sample_count {
|
||||
entries.push(reader.read_be_u32()?);
|
||||
}
|
||||
|
||||
SampleSize::Variable(entries)
|
||||
}
|
||||
else {
|
||||
SampleSize::Constant(sample_size)
|
||||
};
|
||||
|
||||
Ok(StszAtom { header, sample_count, sample_sizes })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SampleDurationEntry {
|
||||
pub sample_count: u32,
|
||||
pub sample_delta: u32,
|
||||
}
|
||||
|
||||
/// Time-to-sample atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct SttsAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub entries: Vec<SampleDurationEntry>,
|
||||
pub total_duration: u64,
|
||||
}
|
||||
|
||||
impl SttsAtom {
|
||||
/// Get the timestamp and duration for the sample indicated by `sample_num`. Note, `sample_num`
|
||||
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
|
||||
pub fn find_timing_for_sample(&self, sample_num: u32) -> Option<(u64, u32)> {
|
||||
let mut ts = 0;
|
||||
let mut next_entry_first_sample = 0;
|
||||
|
||||
// The Stts atom compactly encodes a mapping between number of samples and sample duration.
|
||||
// Iterate through each entry until the entry containing the next sample is found. The next
|
||||
// packet timestamp is then the sum of the product of sample count and sample duration for
|
||||
// the n-1 iterated entries, plus the product of the number of consumed samples in the n-th
|
||||
// iterated entry and sample duration.
|
||||
for entry in &self.entries {
|
||||
next_entry_first_sample += entry.sample_count;
|
||||
|
||||
if sample_num < next_entry_first_sample {
|
||||
let entry_sample_offset = sample_num + entry.sample_count - next_entry_first_sample;
|
||||
ts += u64::from(entry.sample_delta) * u64::from(entry_sample_offset);
|
||||
|
||||
return Some((ts, entry.sample_delta));
|
||||
}
|
||||
|
||||
ts += u64::from(entry.sample_count) * u64::from(entry.sample_delta);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the sample that contains the timestamp indicated by `ts`. Note, the returned `sample_num`
|
||||
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
|
||||
pub fn find_sample_for_timestamp(&self, ts: u64) -> Option<u32> {
|
||||
let mut ts_accum = 0;
|
||||
let mut sample_num = 0;
|
||||
|
||||
for entry in &self.entries {
|
||||
let delta = u64::from(entry.sample_delta) * u64::from(entry.sample_count);
|
||||
|
||||
if ts_accum + delta > ts {
|
||||
sample_num += ((ts - ts_accum) / u64::from(entry.sample_delta)) as u32;
|
||||
return Some(sample_num);
|
||||
}
|
||||
|
||||
ts_accum += delta;
|
||||
sample_num += entry.sample_count;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for SttsAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let entry_count = reader.read_be_u32()?;
|
||||
|
||||
let mut total_duration = 0;
|
||||
|
||||
// TODO: Limit table length.
|
||||
let mut entries = Vec::with_capacity(entry_count as usize);
|
||||
|
||||
for _ in 0..entry_count {
|
||||
let sample_count = reader.read_be_u32()?;
|
||||
let sample_delta = reader.read_be_u32()?;
|
||||
|
||||
total_duration += u64::from(sample_count) * u64::from(sample_delta);
|
||||
|
||||
entries.push(SampleDurationEntry { sample_count, sample_delta });
|
||||
}
|
||||
|
||||
Ok(SttsAtom { header, entries, total_duration })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Track fragment header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TfhdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub track_id: u32,
|
||||
pub base_data_offset: Option<u64>,
|
||||
pub sample_desc_idx: Option<u32>,
|
||||
pub default_sample_duration: Option<u32>,
|
||||
pub default_sample_size: Option<u32>,
|
||||
pub default_sample_flags: Option<u32>,
|
||||
/// If true, there are no samples for this time duration.
|
||||
pub duration_is_empty: bool,
|
||||
/// If true, the base data offset for this track is the first byte of the parent containing moof
|
||||
/// atom.
|
||||
pub default_base_is_moof: bool,
|
||||
}
|
||||
|
||||
impl Atom for TfhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let track_id = reader.read_be_u32()?;
|
||||
|
||||
let base_data_offset = match flags & 0x1 {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u64()?),
|
||||
};
|
||||
|
||||
let sample_desc_idx = match flags & 0x2 {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u32()?),
|
||||
};
|
||||
|
||||
let default_sample_duration = match flags & 0x8 {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u32()?),
|
||||
};
|
||||
|
||||
let default_sample_size = match flags & 0x10 {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u32()?),
|
||||
};
|
||||
|
||||
let default_sample_flags = match flags & 0x20 {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u32()?),
|
||||
};
|
||||
|
||||
let duration_is_empty = (flags & 0x1_0000) != 0;
|
||||
|
||||
// The default-base-is-moof flag is ignored if the base-data-offset flag is set.
|
||||
let default_base_is_moof = (flags & 0x1 == 0) && (flags & 0x2_0000 != 0);
|
||||
|
||||
Ok(TfhdAtom {
|
||||
header,
|
||||
track_id,
|
||||
base_data_offset,
|
||||
sample_desc_idx,
|
||||
default_sample_duration,
|
||||
default_sample_size,
|
||||
default_sample_flags,
|
||||
duration_is_empty,
|
||||
default_base_is_moof,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
use crate::fp::FpU8;
|
||||
|
||||
/// Track header atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TkhdAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Track header flags.
|
||||
pub flags: u32,
|
||||
/// Creation time.
|
||||
pub ctime: u64,
|
||||
/// Modification time.
|
||||
pub mtime: u64,
|
||||
/// Track identifier.
|
||||
pub id: u32,
|
||||
/// Track duration in the timescale units specified in the movie header. This value is equal to
|
||||
/// the sum of the durations of all the track's edits.
|
||||
pub duration: u64,
|
||||
/// Layer.
|
||||
pub layer: u16,
|
||||
/// Grouping identifier.
|
||||
pub alternate_group: u16,
|
||||
/// Preferred volume for track playback.
|
||||
pub volume: FpU8,
|
||||
}
|
||||
|
||||
impl Atom for TkhdAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (version, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let mut tkhd = TkhdAtom {
|
||||
header,
|
||||
flags,
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
id: 0,
|
||||
duration: 0,
|
||||
layer: 0,
|
||||
alternate_group: 0,
|
||||
volume: Default::default(),
|
||||
};
|
||||
|
||||
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
|
||||
match version {
|
||||
0 => {
|
||||
tkhd.ctime = u64::from(reader.read_be_u32()?);
|
||||
tkhd.mtime = u64::from(reader.read_be_u32()?);
|
||||
tkhd.id = reader.read_be_u32()?;
|
||||
let _ = reader.read_be_u32()?; // Reserved
|
||||
tkhd.duration = u64::from(reader.read_be_u32()?);
|
||||
}
|
||||
1 => {
|
||||
tkhd.ctime = reader.read_be_u64()?;
|
||||
tkhd.mtime = reader.read_be_u64()?;
|
||||
tkhd.id = reader.read_be_u32()?;
|
||||
let _ = reader.read_be_u32()?; // Reserved
|
||||
tkhd.duration = reader.read_be_u64()?;
|
||||
}
|
||||
_ => return decode_error("isomp4: invalid tkhd version"),
|
||||
}
|
||||
|
||||
// Reserved
|
||||
let _ = reader.read_be_u64()?;
|
||||
|
||||
tkhd.layer = reader.read_be_u16()?;
|
||||
tkhd.alternate_group = reader.read_be_u16()?;
|
||||
tkhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
|
||||
|
||||
// The remainder of the header is only useful for video tracks.
|
||||
|
||||
Ok(tkhd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, TfhdAtom, TrunAtom};
|
||||
|
||||
/// Track fragment atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TrafAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Track fragment header.
|
||||
pub tfhd: TfhdAtom,
|
||||
/// Track fragment sample runs.
|
||||
pub truns: Vec<TrunAtom>,
|
||||
/// The total number of samples in this track fragment.
|
||||
pub total_sample_count: u32,
|
||||
}
|
||||
|
||||
impl Atom for TrafAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut tfhd = None;
|
||||
let mut truns = Vec::new();
|
||||
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut total_sample_count = 0;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::TrackFragmentHeader => {
|
||||
tfhd = Some(iter.read_atom::<TfhdAtom>()?);
|
||||
}
|
||||
AtomType::TrackFragmentRun => {
|
||||
let trun = iter.read_atom::<TrunAtom>()?;
|
||||
|
||||
// Increment the total sample count.
|
||||
total_sample_count += trun.sample_count;
|
||||
|
||||
truns.push(trun);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
// Tfhd is mandatory.
|
||||
if tfhd.is_none() {
|
||||
return decode_error("isomp4: missing tfhd atom");
|
||||
}
|
||||
|
||||
Ok(TrafAtom { header, tfhd: tfhd.unwrap(), truns, total_sample_count })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, EdtsAtom, MdiaAtom, TkhdAtom};
|
||||
|
||||
/// Track atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TrakAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Track header atom.
|
||||
pub tkhd: TkhdAtom,
|
||||
/// Optional, edit list atom.
|
||||
pub edts: Option<EdtsAtom>,
|
||||
/// Media atom.
|
||||
pub mdia: MdiaAtom,
|
||||
}
|
||||
|
||||
impl Atom for TrakAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut tkhd = None;
|
||||
let mut edts = None;
|
||||
let mut mdia = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::TrackHeader => {
|
||||
tkhd = Some(iter.read_atom::<TkhdAtom>()?);
|
||||
}
|
||||
AtomType::Edit => {
|
||||
edts = Some(iter.read_atom::<EdtsAtom>()?);
|
||||
}
|
||||
AtomType::Media => {
|
||||
mdia = Some(iter.read_atom::<MdiaAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if tkhd.is_none() {
|
||||
return decode_error("isomp4: missing tkhd atom");
|
||||
}
|
||||
|
||||
if mdia.is_none() {
|
||||
return decode_error("isomp4: missing mdia atom");
|
||||
}
|
||||
|
||||
Ok(TrakAtom { header, tkhd: tkhd.unwrap(), edts, mdia: mdia.unwrap() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Track extends atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TrexAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Track this atom describes.
|
||||
pub track_id: u32,
|
||||
/// Default sample description index.
|
||||
pub default_sample_desc_idx: u32,
|
||||
/// Default sample duration.
|
||||
pub default_sample_duration: u32,
|
||||
/// Default sample size.
|
||||
pub default_sample_size: u32,
|
||||
/// Default sample flags.
|
||||
pub default_sample_flags: u32,
|
||||
}
|
||||
|
||||
impl Atom for TrexAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, _) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
Ok(TrexAtom {
|
||||
header,
|
||||
track_id: reader.read_be_u32()?,
|
||||
default_sample_desc_idx: reader.read_be_u32()?,
|
||||
default_sample_duration: reader.read_be_u32()?,
|
||||
default_sample_size: reader.read_be_u32()?,
|
||||
default_sample_flags: reader.read_be_u32()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::{decode_error, Result};
|
||||
use symphonia_core::io::ReadBytes;
|
||||
use symphonia_core::util::bits;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader};
|
||||
|
||||
/// Track fragment run atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct TrunAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Extended header flags.
|
||||
flags: u32,
|
||||
/// Data offset of this run.
|
||||
pub data_offset: Option<i32>,
|
||||
/// Number of samples in this run.
|
||||
pub sample_count: u32,
|
||||
/// Sample flags for the first sample only.
|
||||
pub first_sample_flags: Option<u32>,
|
||||
/// Sample duration for each sample in this run.
|
||||
pub sample_duration: Vec<u32>,
|
||||
/// Sample size for each sample in this run.
|
||||
pub sample_size: Vec<u32>,
|
||||
/// Sample flags for each sample in this run.
|
||||
pub sample_flags: Vec<u32>,
|
||||
/// The total size of all samples in this run. 0 if the sample size flag is not set.
|
||||
total_sample_size: u64,
|
||||
/// The total duration of all samples in this run. 0 if the sample duration flag is not set.
|
||||
total_sample_duration: u64,
|
||||
}
|
||||
|
||||
impl TrunAtom {
|
||||
// Track fragment run atom flags.
|
||||
const DATA_OFFSET_PRESENT: u32 = 0x1;
|
||||
const FIRST_SAMPLE_FLAGS_PRESENT: u32 = 0x4;
|
||||
const SAMPLE_DURATION_PRESENT: u32 = 0x100;
|
||||
const SAMPLE_SIZE_PRESENT: u32 = 0x200;
|
||||
const SAMPLE_FLAGS_PRESENT: u32 = 0x400;
|
||||
const SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT: u32 = 0x800;
|
||||
|
||||
/// Indicates if sample durations are provided.
|
||||
pub fn is_sample_duration_present(&self) -> bool {
|
||||
self.flags & TrunAtom::SAMPLE_DURATION_PRESENT != 0
|
||||
}
|
||||
|
||||
// Indicates if the duration of the first sample is provided.
|
||||
pub fn is_first_sample_duration_present(&self) -> bool {
|
||||
match self.first_sample_flags {
|
||||
Some(flags) => flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT != 0,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates if sample sizes are provided.
|
||||
pub fn is_sample_size_present(&self) -> bool {
|
||||
self.flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0
|
||||
}
|
||||
|
||||
/// Indicates if the size for the first sample is provided.
|
||||
pub fn is_first_sample_size_present(&self) -> bool {
|
||||
match self.first_sample_flags {
|
||||
Some(flags) => flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates if sample flags are provided.
|
||||
#[allow(dead_code)]
|
||||
pub fn are_sample_flags_present(&self) -> bool {
|
||||
self.flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0
|
||||
}
|
||||
|
||||
/// Indicates if sample composition time offsets are provided.
|
||||
#[allow(dead_code)]
|
||||
pub fn are_sample_composition_time_offsets_present(&self) -> bool {
|
||||
self.flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT != 0
|
||||
}
|
||||
|
||||
/// Gets the total duration of all samples.
|
||||
pub fn total_duration(&self, default_dur: u32) -> u64 {
|
||||
if self.is_sample_duration_present() {
|
||||
self.total_sample_duration
|
||||
}
|
||||
else {
|
||||
// The duration of all samples in the track fragment are not explictly known.
|
||||
if self.sample_count > 0 && self.is_first_sample_duration_present() {
|
||||
// The first sample has an explictly recorded duration.
|
||||
u64::from(self.sample_duration[0])
|
||||
+ u64::from(self.sample_count - 1) * u64::from(default_dur)
|
||||
}
|
||||
else {
|
||||
// All samples have the default duration.
|
||||
u64::from(self.sample_count) * u64::from(default_dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the total size of all samples.
|
||||
pub fn total_size(&self, default_size: u32) -> u64 {
|
||||
if self.is_sample_size_present() {
|
||||
self.total_sample_size
|
||||
}
|
||||
else if self.sample_count > 0 && self.is_first_sample_size_present() {
|
||||
u64::from(self.sample_size[0])
|
||||
+ u64::from(self.sample_count - 1) * u64::from(default_size)
|
||||
}
|
||||
else {
|
||||
u64::from(self.sample_count) * u64::from(default_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the timestamp and duration of a sample. The desired sample is specified by the
|
||||
/// trun-relative sample number, `sample_num_rel`.
|
||||
pub fn sample_timing(&self, sample_num_rel: u32, default_dur: u32) -> (u64, u32) {
|
||||
debug_assert!(sample_num_rel < self.sample_count);
|
||||
|
||||
if self.is_sample_duration_present() {
|
||||
// All sample durations are unique.
|
||||
let ts = if sample_num_rel > 0 {
|
||||
self.sample_duration[..sample_num_rel as usize]
|
||||
.iter()
|
||||
.map(|&s| u64::from(s))
|
||||
.sum::<u64>()
|
||||
}
|
||||
else {
|
||||
0
|
||||
};
|
||||
|
||||
let dur = self.sample_duration[sample_num_rel as usize];
|
||||
|
||||
(ts, dur)
|
||||
}
|
||||
else {
|
||||
// The duration of all samples in the track fragment are not unique.
|
||||
let ts = if sample_num_rel > 0 && self.is_first_sample_duration_present() {
|
||||
// The first sample has a unique duration.
|
||||
u64::from(self.sample_duration[0])
|
||||
+ u64::from(sample_num_rel - 1) * u64::from(default_dur)
|
||||
}
|
||||
else {
|
||||
// Zero or more samples with identical durations.
|
||||
u64::from(sample_num_rel) * u64::from(default_dur)
|
||||
};
|
||||
|
||||
(ts, default_dur)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the size of a sample. The desired sample is specified by the trun-relative sample
|
||||
/// number, `sample_num_rel`.
|
||||
pub fn sample_size(&self, sample_num_rel: u32, default_size: u32) -> u32 {
|
||||
debug_assert!(sample_num_rel < self.sample_count);
|
||||
|
||||
if self.is_sample_size_present() {
|
||||
self.sample_size[sample_num_rel as usize]
|
||||
}
|
||||
else if sample_num_rel == 0 && self.is_first_sample_size_present() {
|
||||
self.sample_size[0]
|
||||
}
|
||||
else {
|
||||
default_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the byte offset and size of a sample. The desired sample is specified by the
|
||||
/// trun-relative sample number, `sample_num_rel`.
|
||||
pub fn sample_offset(&self, sample_num_rel: u32, default_size: u32) -> (u64, u32) {
|
||||
debug_assert!(sample_num_rel < self.sample_count);
|
||||
|
||||
if self.is_sample_size_present() {
|
||||
// All sample sizes are unique.
|
||||
let offset = if sample_num_rel > 0 {
|
||||
self.sample_size[..sample_num_rel as usize]
|
||||
.iter()
|
||||
.map(|&s| u64::from(s))
|
||||
.sum::<u64>()
|
||||
}
|
||||
else {
|
||||
0
|
||||
};
|
||||
|
||||
(offset, self.sample_size[sample_num_rel as usize])
|
||||
}
|
||||
else {
|
||||
// The size of all samples in the track are not unique.
|
||||
let offset = if sample_num_rel > 0 && self.is_first_sample_size_present() {
|
||||
// The first sample has a unique size.
|
||||
u64::from(self.sample_size[0])
|
||||
+ u64::from(sample_num_rel - 1) * u64::from(default_size)
|
||||
}
|
||||
else {
|
||||
// Zero or more identically sized samples.
|
||||
u64::from(sample_num_rel) * u64::from(default_size)
|
||||
};
|
||||
|
||||
(offset, default_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the sample number (relative to the trun) of the sample that contains timestamp `ts`.
|
||||
pub fn ts_sample(&self, ts_rel: u64, default_dur: u32) -> u32 {
|
||||
let mut sample_num = 0;
|
||||
let mut ts_delta = ts_rel;
|
||||
|
||||
if self.is_sample_duration_present() {
|
||||
// If the sample durations are present, then each sample duration is independently
|
||||
// stored. Sum sample durations until the delta is reached.
|
||||
for &dur in &self.sample_duration {
|
||||
if u64::from(dur) > ts_delta {
|
||||
break;
|
||||
}
|
||||
|
||||
ts_delta -= u64::from(dur);
|
||||
sample_num += 1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if self.sample_count > 0 && self.is_first_sample_duration_present() {
|
||||
// The first sample duration is unique.
|
||||
let first_sample_dur = u64::from(self.sample_duration[0]);
|
||||
|
||||
if ts_delta >= first_sample_dur {
|
||||
ts_delta -= first_sample_dur;
|
||||
sample_num += 1;
|
||||
}
|
||||
else {
|
||||
ts_delta -= ts_delta;
|
||||
}
|
||||
}
|
||||
|
||||
sample_num += ts_delta.checked_div(u64::from(default_dur)).unwrap_or(0) as u32;
|
||||
}
|
||||
|
||||
sample_num
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for TrunAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let (_, flags) = AtomHeader::read_extra(reader)?;
|
||||
|
||||
let sample_count = reader.read_be_u32()?;
|
||||
|
||||
let data_offset = match flags & TrunAtom::DATA_OFFSET_PRESENT {
|
||||
0 => None,
|
||||
_ => Some(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
|
||||
};
|
||||
|
||||
let first_sample_flags = match flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT {
|
||||
0 => None,
|
||||
_ => Some(reader.read_be_u32()?),
|
||||
};
|
||||
|
||||
// If the first-sample-flags-present flag is set, then the sample-flags-present flag should
|
||||
// not be set. The samples after the first shall use the default sample flags defined in the
|
||||
// tfhd or mvex atoms.
|
||||
if first_sample_flags.is_some() && (flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0) {
|
||||
return decode_error(
|
||||
"isomp4: sample-flag-present and first-sample-flags-present flags are set",
|
||||
);
|
||||
}
|
||||
|
||||
let mut sample_duration = Vec::new();
|
||||
let mut sample_size = Vec::new();
|
||||
let mut sample_flags = Vec::new();
|
||||
|
||||
let mut total_sample_size = 0;
|
||||
let mut total_sample_duration = 0;
|
||||
|
||||
// TODO: Apply a limit.
|
||||
for _ in 0..sample_count {
|
||||
if (flags & TrunAtom::SAMPLE_DURATION_PRESENT) != 0 {
|
||||
let duration = reader.read_be_u32()?;
|
||||
total_sample_duration += u64::from(duration);
|
||||
sample_duration.push(duration);
|
||||
}
|
||||
|
||||
if (flags & TrunAtom::SAMPLE_SIZE_PRESENT) != 0 {
|
||||
let size = reader.read_be_u32()?;
|
||||
total_sample_size += u64::from(size);
|
||||
sample_size.push(size);
|
||||
}
|
||||
|
||||
if (flags & TrunAtom::SAMPLE_FLAGS_PRESENT) != 0 {
|
||||
sample_flags.push(reader.read_be_u32()?);
|
||||
}
|
||||
|
||||
// Ignoring composition time for now since it's a video thing...
|
||||
if (flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT) != 0 {
|
||||
// For version 0, this is a u32.
|
||||
// For version 1, this is a i32.
|
||||
let _ = reader.read_be_u32()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TrunAtom {
|
||||
header,
|
||||
flags,
|
||||
data_offset,
|
||||
sample_count,
|
||||
first_sample_flags,
|
||||
sample_duration,
|
||||
sample_size,
|
||||
sample_flags,
|
||||
total_sample_size,
|
||||
total_sample_duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
use symphonia_core::meta::MetadataRevision;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MetaAtom};
|
||||
|
||||
/// User data atom.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct UdtaAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
/// Metadata atom.
|
||||
pub meta: Option<MetaAtom>,
|
||||
}
|
||||
|
||||
impl UdtaAtom {
|
||||
/// If metadata was read, consumes the metadata and returns it.
|
||||
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
|
||||
self.meta.as_mut().and_then(|meta| meta.take_metadata())
|
||||
}
|
||||
}
|
||||
|
||||
impl Atom for UdtaAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
#[allow(clippy::single_match)]
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut meta = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
match header.atype {
|
||||
AtomType::Meta => {
|
||||
meta = Some(iter.read_atom::<MetaAtom>()?);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UdtaAtom { header, meta })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::errors::Result;
|
||||
use symphonia_core::io::ReadBytes;
|
||||
|
||||
use crate::atoms::{Atom, AtomHeader, EsdsAtom};
|
||||
|
||||
use super::{AtomIterator, AtomType};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct WaveAtom {
|
||||
/// Atom header.
|
||||
header: AtomHeader,
|
||||
pub esds: Option<EsdsAtom>,
|
||||
}
|
||||
|
||||
impl Atom for WaveAtom {
|
||||
fn header(&self) -> AtomHeader {
|
||||
self.header
|
||||
}
|
||||
|
||||
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
|
||||
let mut iter = AtomIterator::new(reader, header);
|
||||
|
||||
let mut esds = None;
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
if header.atype == AtomType::Esds {
|
||||
esds = Some(iter.read_atom::<EsdsAtom>()?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WaveAtom { header, esds })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use symphonia_core::{errors::end_of_stream_error, support_format};
|
||||
|
||||
use symphonia_core::codecs::CodecParameters;
|
||||
use symphonia_core::errors::{
|
||||
decode_error, seek_error, unsupported_error, Error, Result, SeekErrorKind,
|
||||
};
|
||||
use symphonia_core::formats::prelude::*;
|
||||
use symphonia_core::io::{MediaSource, MediaSourceStream, ReadBytes, SeekBuffered};
|
||||
use symphonia_core::meta::{Metadata, MetadataLog};
|
||||
use symphonia_core::probe::{Descriptor, Instantiate, QueryDescriptor};
|
||||
use symphonia_core::units::Time;
|
||||
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::atoms::{AtomIterator, AtomType};
|
||||
use crate::atoms::{FtypAtom, MetaAtom, MoofAtom, MoovAtom, MvexAtom, SidxAtom, TrakAtom};
|
||||
use crate::stream::*;
|
||||
|
||||
use log::{debug, info, trace, warn};
|
||||
|
||||
pub struct TrackState {
|
||||
codec_params: CodecParameters,
|
||||
/// The track number.
|
||||
track_num: usize,
|
||||
/// The current segment.
|
||||
cur_seg: usize,
|
||||
/// The current sample index relative to the track.
|
||||
next_sample: u32,
|
||||
/// The current sample byte position relative to the start of the track.
|
||||
next_sample_pos: u64,
|
||||
}
|
||||
|
||||
impl TrackState {
|
||||
#[allow(clippy::single_match)]
|
||||
pub fn new(track_num: usize, trak: &TrakAtom) -> Self {
|
||||
let mut codec_params = CodecParameters::new();
|
||||
|
||||
codec_params
|
||||
.with_time_base(TimeBase::new(1, trak.mdia.mdhd.timescale))
|
||||
.with_n_frames(trak.mdia.mdhd.duration);
|
||||
|
||||
// Fill the codec parameters using the sample description atom.
|
||||
trak.mdia.minf.stbl.stsd.fill_codec_params(&mut codec_params);
|
||||
|
||||
Self { codec_params, track_num, cur_seg: 0, next_sample: 0, next_sample_pos: 0 }
|
||||
}
|
||||
|
||||
pub fn codec_params(&self) -> CodecParameters {
|
||||
self.codec_params.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Information regarding the next sample.
|
||||
#[derive(Debug)]
|
||||
struct NextSampleInfo {
|
||||
/// The track number of the next sample.
|
||||
track_num: usize,
|
||||
/// The timestamp of the next sample.
|
||||
ts: u64,
|
||||
/// The timestamp expressed in seconds.
|
||||
time: Time,
|
||||
/// The duration of the next sample.
|
||||
dur: u32,
|
||||
/// The segment containing the next sample.
|
||||
seg_idx: usize,
|
||||
}
|
||||
|
||||
/// Information regarding a sample.
|
||||
#[derive(Debug)]
|
||||
struct SampleDataInfo {
|
||||
/// The position of the sample in the track.
|
||||
pos: u64,
|
||||
/// The length of the sample.
|
||||
len: u32,
|
||||
}
|
||||
|
||||
/// ISO Base Media File Format (MP4, M4A, MOV, etc.) demultiplexer.
|
||||
///
|
||||
/// `IsoMp4Reader` implements a demuxer for the ISO Base Media File Format.
|
||||
pub struct IsoMp4Reader {
|
||||
iter: AtomIterator<MediaSourceStream>,
|
||||
tracks: Vec<Track>,
|
||||
cues: Vec<Cue>,
|
||||
metadata: MetadataLog,
|
||||
/// Segments of the movie. Sorted in ascending order by sequence number.
|
||||
segs: Vec<Box<dyn StreamSegment>>,
|
||||
/// State tracker for each track.
|
||||
track_states: Vec<TrackState>,
|
||||
/// Optional, movie extends atom used for fragmented streams.
|
||||
mvex: Option<Arc<MvexAtom>>,
|
||||
}
|
||||
|
||||
impl IsoMp4Reader {
|
||||
/// Idempotently gets information regarding the next sample of the media stream. This function
|
||||
/// selects the next sample with the lowest timestamp of all tracks.
|
||||
fn next_sample_info(&self) -> Result<Option<NextSampleInfo>> {
|
||||
let mut earliest = None;
|
||||
|
||||
// TODO: Consider returning samples based on lowest byte position in the track instead of
|
||||
// timestamp. This may be important if video tracks are ever decoded (i.e., DTS vs. PTS).
|
||||
|
||||
for (state, track) in self.track_states.iter().zip(&self.tracks) {
|
||||
// Get the timebase of the track used to calculate the presentation time.
|
||||
let tb = track.codec_params.time_base.unwrap();
|
||||
|
||||
// Get the next timestamp for the next sample of the current track. The next sample may
|
||||
// be in a future segment.
|
||||
for (seg_idx_delta, seg) in self.segs[state.cur_seg..].iter().enumerate() {
|
||||
// Try to get the timestamp for the next sample of the track from the segment.
|
||||
if let Some(timing) = seg.sample_timing(state.track_num, state.next_sample)? {
|
||||
// Calculate the presentation time using the timestamp.
|
||||
let sample_time = tb.calc_time(timing.ts);
|
||||
|
||||
// Compare the presentation time of the sample from this track to other tracks,
|
||||
// and select the track with the earliest presentation time.
|
||||
match earliest {
|
||||
Some(NextSampleInfo { track_num: _, ts: _, time, dur: _, seg_idx: _ })
|
||||
if time <= sample_time =>
|
||||
{
|
||||
// Earliest is less than or equal to the track's next sample
|
||||
// presentation time. No need to update earliest.
|
||||
}
|
||||
_ => {
|
||||
// Earliest was either None, or greater than the track's next sample
|
||||
// presentation time. Update earliest.
|
||||
earliest = Some(NextSampleInfo {
|
||||
track_num: state.track_num,
|
||||
ts: timing.ts,
|
||||
time: sample_time,
|
||||
dur: timing.dur,
|
||||
seg_idx: seg_idx_delta + state.cur_seg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Either the next sample of the track had the earliest presentation time seen
|
||||
// thus far, or it was greater than those from other tracks, but there is no
|
||||
// reason to check samples in future segments.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(earliest)
|
||||
}
|
||||
|
||||
fn consume_next_sample(&mut self, info: &NextSampleInfo) -> Result<Option<SampleDataInfo>> {
|
||||
// Get the track state.
|
||||
let track = &mut self.track_states[info.track_num];
|
||||
|
||||
// Get the segment associated with the sample.
|
||||
let seg = &self.segs[info.seg_idx];
|
||||
|
||||
// Get the sample data descriptor.
|
||||
let sample_data_desc = seg.sample_data(track.track_num, track.next_sample, false)?;
|
||||
|
||||
// The sample base position in the sample data descriptor remains constant if the sample
|
||||
// followed immediately after the previous sample. In this case, the track state's
|
||||
// next_sample_pos is the position of the current sample. If the base position has jumped,
|
||||
// then the base position is the position of current the sample.
|
||||
let pos = if sample_data_desc.base_pos > track.next_sample_pos {
|
||||
sample_data_desc.base_pos
|
||||
}
|
||||
else {
|
||||
track.next_sample_pos
|
||||
};
|
||||
|
||||
// Advance the track's current segment to the next sample's segment.
|
||||
track.cur_seg = info.seg_idx;
|
||||
|
||||
// Advance the track's next sample number and position.
|
||||
track.next_sample += 1;
|
||||
track.next_sample_pos = pos + u64::from(sample_data_desc.size);
|
||||
|
||||
Ok(Some(SampleDataInfo { pos, len: sample_data_desc.size }))
|
||||
}
|
||||
|
||||
fn try_read_more_segments(&mut self) -> Result<()> {
|
||||
// Continue iterating over atoms until a segment (a moof + mdat atom pair) is found. All
|
||||
// other atoms will be ignored.
|
||||
while let Some(header) = self.iter.next_no_consume()? {
|
||||
match header.atype {
|
||||
AtomType::MediaData => {
|
||||
// Consume the atom from the iterator so that on the next iteration a new atom
|
||||
// will be read.
|
||||
self.iter.consume_atom();
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
AtomType::MovieFragment => {
|
||||
let moof = self.iter.read_atom::<MoofAtom>()?;
|
||||
|
||||
// A moof segment can only be created if the mvex atom is present.
|
||||
if let Some(mvex) = &self.mvex {
|
||||
// Get the last segment. Note, there will always be one segment because the
|
||||
// moov atom is converted into a segment when the reader is instantiated.
|
||||
let last_seg = self.segs.last().unwrap();
|
||||
|
||||
// Create a new segment for the moof atom.
|
||||
let seg = MoofSegment::new(moof, mvex.clone(), last_seg.as_ref());
|
||||
|
||||
// Segments should have a monotonic sequence number.
|
||||
if seg.sequence_num() <= last_seg.sequence_num() {
|
||||
warn!("moof fragment has a non-monotonic sequence number.");
|
||||
}
|
||||
|
||||
// Push the segment.
|
||||
self.segs.push(Box::new(seg));
|
||||
}
|
||||
else {
|
||||
// TODO: This is a fatal error.
|
||||
return decode_error("isomp4: moof atom present without mvex atom");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("skipping atom: {:?}.", header.atype);
|
||||
self.iter.consume_atom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no atoms were returned above, then the end-of-stream has been reached.
|
||||
end_of_stream_error()
|
||||
}
|
||||
|
||||
fn seek_track_by_time(&mut self, track_num: usize, time: Time) -> Result<SeekedTo> {
|
||||
// Convert time to timestamp for the track.
|
||||
if let Some(track) = self.tracks.get(track_num) {
|
||||
let tb = track.codec_params.time_base.unwrap();
|
||||
self.seek_track_by_ts(track_num, tb.calc_timestamp(time))
|
||||
}
|
||||
else {
|
||||
seek_error(SeekErrorKind::Unseekable)
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_track_by_ts(&mut self, track_num: usize, ts: u64) -> Result<SeekedTo> {
|
||||
debug!("seeking track={} to frame_ts={}", track_num, ts);
|
||||
|
||||
struct SeekLocation {
|
||||
seg_idx: usize,
|
||||
sample_num: u32,
|
||||
}
|
||||
|
||||
let mut seek_loc = None;
|
||||
let mut seg_skip = 0;
|
||||
|
||||
loop {
|
||||
// Iterate over all segments and attempt to find the segment and sample number that
|
||||
// contains the desired timestamp. Skip segments already examined.
|
||||
for (seg_idx, seg) in self.segs.iter().enumerate().skip(seg_skip) {
|
||||
if let Some(sample_num) = seg.ts_sample(track_num, ts)? {
|
||||
seek_loc = Some(SeekLocation { seg_idx, sample_num });
|
||||
break;
|
||||
}
|
||||
|
||||
// Mark the segment as examined.
|
||||
seg_skip = seg_idx + 1;
|
||||
}
|
||||
|
||||
// If a seek location is found, break.
|
||||
if seek_loc.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise, try to read more segments from the stream.
|
||||
self.try_read_more_segments()?;
|
||||
}
|
||||
|
||||
if let Some(seek_loc) = seek_loc {
|
||||
let seg = &self.segs[seek_loc.seg_idx];
|
||||
|
||||
// Get the sample information.
|
||||
let data_desc = seg.sample_data(track_num, seek_loc.sample_num, true)?;
|
||||
|
||||
// Update the track's next sample information to point to the seeked sample.
|
||||
let track = &mut self.track_states[track_num];
|
||||
|
||||
track.cur_seg = seek_loc.seg_idx;
|
||||
track.next_sample = seek_loc.sample_num;
|
||||
track.next_sample_pos = data_desc.base_pos + data_desc.offset.unwrap();
|
||||
|
||||
// Get the actual timestamp for this sample.
|
||||
let timing = seg.sample_timing(track_num, seek_loc.sample_num)?.unwrap();
|
||||
|
||||
debug!(
|
||||
"seeked track={} to packet_ts={} (delta={})",
|
||||
track_num,
|
||||
timing.ts,
|
||||
timing.ts as i64 - ts as i64
|
||||
);
|
||||
|
||||
Ok(SeekedTo { track_id: track_num as u32, required_ts: ts, actual_ts: timing.ts })
|
||||
}
|
||||
else {
|
||||
// Timestamp was not found.
|
||||
seek_error(SeekErrorKind::OutOfRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueryDescriptor for IsoMp4Reader {
|
||||
fn query() -> &'static [Descriptor] {
|
||||
&[support_format!(
|
||||
"isomp4",
|
||||
"ISO Base Media File Format",
|
||||
&["mp4", "m4a", "m4p", "m4b", "m4r", "m4v", "mov"],
|
||||
&["video/mp4", "audio/mp4"],
|
||||
&[b"ftyp"] // Top-level atoms
|
||||
)]
|
||||
}
|
||||
|
||||
fn score(_context: &[u8]) -> u8 {
|
||||
255
|
||||
}
|
||||
}
|
||||
|
||||
impl FormatReader for IsoMp4Reader {
|
||||
fn try_new(mut mss: MediaSourceStream, _options: &FormatOptions) -> Result<Self> {
|
||||
// To get to beginning of the atom.
|
||||
mss.seek_buffered_rel(-4);
|
||||
|
||||
let is_seekable = mss.is_seekable();
|
||||
|
||||
let mut ftyp = None;
|
||||
let mut moov = None;
|
||||
let mut sidx = None;
|
||||
|
||||
// Get the total length of the stream, if possible.
|
||||
let total_len = if is_seekable {
|
||||
let pos = mss.pos();
|
||||
let len = mss.byte_len().ok_or(Error::SeekError(SeekErrorKind::Unseekable))?;
|
||||
mss.seek(SeekFrom::Start(pos))?;
|
||||
info!("stream is seekable with len={} bytes.", len);
|
||||
Some(len)
|
||||
}
|
||||
else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut metadata = MetadataLog::default();
|
||||
|
||||
// Parse all atoms if the stream is seekable, otherwise parse all atoms up-to the mdat atom.
|
||||
let mut iter = AtomIterator::new_root(mss, total_len);
|
||||
|
||||
while let Some(header) = iter.next()? {
|
||||
// Top-level atoms.
|
||||
match header.atype {
|
||||
AtomType::FileType => {
|
||||
ftyp = Some(iter.read_atom::<FtypAtom>()?);
|
||||
}
|
||||
AtomType::Movie => {
|
||||
moov = Some(iter.read_atom::<MoovAtom>()?);
|
||||
}
|
||||
AtomType::SegmentIndex => {
|
||||
// If the stream is not seekable, then it can only be assumed that the first
|
||||
// segment index atom is indeed the first segment index because the format
|
||||
// reader cannot practically skip past this point.
|
||||
if !is_seekable {
|
||||
sidx = Some(iter.read_atom::<SidxAtom>()?);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
// If the stream is seekable, examine all segment indexes and select the
|
||||
// index with the earliest presentation timestamp to be the first.
|
||||
let new_sidx = iter.read_atom::<SidxAtom>()?;
|
||||
|
||||
let is_earlier = match &sidx {
|
||||
Some(sidx) => new_sidx.earliest_pts < sidx.earliest_pts,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if is_earlier {
|
||||
sidx = Some(new_sidx);
|
||||
}
|
||||
}
|
||||
}
|
||||
AtomType::MediaData | AtomType::MovieFragment => {
|
||||
// The mdat atom contains the codec bitstream data. For segmented streams, a
|
||||
// moof + mdat pair is required for playback. If the source is unseekable then
|
||||
// the format reader cannot skip past these atoms without dropping samples.
|
||||
if !is_seekable {
|
||||
// If the moov atom hasn't been seen before the moof and/or mdat atom, and
|
||||
// the stream is not seekable, then the mp4 is not streamable.
|
||||
if moov.is_none() || ftyp.is_none() {
|
||||
warn!("mp4 is not streamable.");
|
||||
}
|
||||
|
||||
// The remainder of the stream will be read incrementally.
|
||||
break;
|
||||
}
|
||||
}
|
||||
AtomType::Meta => {
|
||||
// Read the metadata atom and append it to the log.
|
||||
let mut meta = iter.read_atom::<MetaAtom>()?;
|
||||
|
||||
if let Some(rev) = meta.take_metadata() {
|
||||
metadata.push(rev);
|
||||
}
|
||||
}
|
||||
AtomType::Free => (),
|
||||
AtomType::Skip => (),
|
||||
_ => {
|
||||
info!("skipping top-level atom: {:?}.", header.atype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ftyp.is_none() {
|
||||
return unsupported_error("isomp4: missing ftyp atom");
|
||||
}
|
||||
|
||||
if moov.is_none() {
|
||||
return unsupported_error("isomp4: missing moov atom");
|
||||
}
|
||||
|
||||
// If the stream was seekable, then all atoms in the media source stream were scanned. Seek
|
||||
// back to the first mdat atom for playback. If the stream is not seekable, then the atom
|
||||
// iterator is currently positioned at the first mdat atom.
|
||||
if is_seekable {
|
||||
let mut mss = iter.into_inner();
|
||||
mss.seek(SeekFrom::Start(0))?;
|
||||
|
||||
iter = AtomIterator::new_root(mss, total_len);
|
||||
|
||||
while let Some(header) = iter.next_no_consume()? {
|
||||
match header.atype {
|
||||
AtomType::MediaData | AtomType::MovieFragment => break,
|
||||
_ => (),
|
||||
}
|
||||
iter.consume_atom();
|
||||
}
|
||||
}
|
||||
|
||||
let mut moov = moov.unwrap();
|
||||
|
||||
if moov.is_fragmented() {
|
||||
// If a Segment Index (sidx) atom was found, add the segments contained within.
|
||||
if sidx.is_some() {
|
||||
info!("stream is segmented with a segment index.");
|
||||
}
|
||||
else {
|
||||
info!("stream is segmented without a segment index.");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rev) = moov.take_metadata() {
|
||||
metadata.push(rev);
|
||||
}
|
||||
|
||||
// Instantiate a TrackState for each track in the stream.
|
||||
let track_states = moov
|
||||
.traks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(t, trak)| TrackState::new(t, trak))
|
||||
.collect::<Vec<TrackState>>();
|
||||
|
||||
// Instantiate a Tracks for all tracks above.
|
||||
let tracks = track_states
|
||||
.iter()
|
||||
.map(|track| Track::new(track.track_num as u32, track.codec_params()))
|
||||
.collect();
|
||||
|
||||
// A Movie Extends (mvex) atom is required to support segmented streams. If the mvex atom is
|
||||
// present, wrap it in an Arc so it can be shared amongst all segments.
|
||||
let mvex = moov.mvex.take().map(Arc::new);
|
||||
|
||||
// The number of tracks specified in the moov atom must match the number in the mvex atom.
|
||||
if let Some(mvex) = &mvex {
|
||||
if mvex.trexs.len() != moov.traks.len() {
|
||||
return decode_error("isomp4: mvex and moov track number mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
let segs: Vec<Box<dyn StreamSegment>> = vec![Box::new(MoovSegment::new(moov))];
|
||||
|
||||
Ok(IsoMp4Reader {
|
||||
iter,
|
||||
tracks,
|
||||
cues: Default::default(),
|
||||
metadata,
|
||||
track_states,
|
||||
segs,
|
||||
mvex,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_packet(&mut self) -> Result<Packet> {
|
||||
// Get the index of the track with the next-nearest (minimum) timestamp.
|
||||
let next_sample_info = loop {
|
||||
// Using the current set of segments, try to get the next sample info.
|
||||
if let Some(info) = self.next_sample_info()? {
|
||||
break info;
|
||||
}
|
||||
else {
|
||||
// No more segments. If the stream is unseekable, it may be the case that there are
|
||||
// more segments coming. Iterate atoms until a new segment is found or the
|
||||
// end-of-stream is reached.
|
||||
self.try_read_more_segments()?;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the position and length information of the next sample.
|
||||
let sample_info = self.consume_next_sample(&next_sample_info)?.unwrap();
|
||||
|
||||
let reader = self.iter.inner_mut();
|
||||
|
||||
// Attempt a fast seek within the buffer cache.
|
||||
if reader.seek_buffered(sample_info.pos) != sample_info.pos {
|
||||
if reader.is_seekable() {
|
||||
// Fallback to a slow seek if the stream is seekable.
|
||||
reader.seek(SeekFrom::Start(sample_info.pos))?;
|
||||
}
|
||||
else if sample_info.pos > reader.pos() {
|
||||
// The stream is not seekable but the desired seek position is ahead of the reader's
|
||||
// current position, thus the seek can be emulated by ignoring the bytes up to the
|
||||
// the desired seek position.
|
||||
reader.ignore_bytes(sample_info.pos - reader.pos())?;
|
||||
}
|
||||
else {
|
||||
// The stream is not seekable and the desired seek position falls outside the lower
|
||||
// bound of the buffer cache. This sample cannot be read.
|
||||
return decode_error("isomp4: packet out-of-bounds for a non-seekable stream");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Packet::new_from_boxed_slice(
|
||||
next_sample_info.track_num as u32,
|
||||
next_sample_info.ts,
|
||||
u64::from(next_sample_info.dur),
|
||||
reader.read_boxed_slice_exact(sample_info.len as usize)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn metadata(&mut self) -> Metadata<'_> {
|
||||
self.metadata.metadata()
|
||||
}
|
||||
|
||||
fn cues(&self) -> &[Cue] {
|
||||
&self.cues
|
||||
}
|
||||
|
||||
fn tracks(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
fn seek(&mut self, _mode: SeekMode, to: SeekTo) -> Result<SeekedTo> {
|
||||
if self.tracks.is_empty() {
|
||||
return seek_error(SeekErrorKind::Unseekable);
|
||||
}
|
||||
|
||||
match to {
|
||||
SeekTo::TimeStamp { ts, track_id } => {
|
||||
let selected_track_id = track_id as usize;
|
||||
|
||||
// The seek timestamp is in timebase units specific to the selected track. Get the
|
||||
// selected track and use the timebase to convert the timestamp into time units so
|
||||
// that the other tracks can be seeked.
|
||||
if let Some(selected_track) = self.tracks().get(selected_track_id) {
|
||||
// Convert to time units.
|
||||
let time = selected_track.codec_params.time_base.unwrap().calc_time(ts);
|
||||
|
||||
// Seek all tracks excluding the primary track to the desired time.
|
||||
for t in 0..self.track_states.len() {
|
||||
if t != selected_track_id {
|
||||
self.seek_track_by_time(t, time)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Seek the primary track and return the result.
|
||||
self.seek_track_by_ts(selected_track_id, ts)
|
||||
}
|
||||
else {
|
||||
seek_error(SeekErrorKind::Unseekable)
|
||||
}
|
||||
}
|
||||
SeekTo::Time { time, track_id } => {
|
||||
// Select the first track if a selected track was not provided.
|
||||
let selected_track_id = track_id.unwrap_or(0) as usize;
|
||||
|
||||
// Seek all tracks excluding the selected track and discard the result.
|
||||
for t in 0..self.track_states.len() {
|
||||
if t != selected_track_id {
|
||||
self.seek_track_by_time(t, time)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Seek the primary track and return the result.
|
||||
self.seek_track_by_time(selected_track_id, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_inner(self: Box<Self>) -> MediaSourceStream {
|
||||
self.iter.into_inner()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Four character codes for typical Ftyps (reference: http://ftyps.com/).
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
#[repr(transparent)]
|
||||
pub struct FourCc {
|
||||
val: [u8; 4],
|
||||
}
|
||||
|
||||
impl FourCc {
|
||||
/// Construct a new FourCC code from the given byte array.
|
||||
pub fn new(val: [u8; 4]) -> Self {
|
||||
Self { val }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FourCc {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match std::str::from_utf8(&self.val) {
|
||||
Ok(name) => f.write_str(name),
|
||||
_ => write!(f, "{:x?}", self.val),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
/// An unsigned 16.16-bit fixed point value.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct FpU16(u32);
|
||||
|
||||
impl FpU16 {
|
||||
pub fn new(val: u16) -> Self {
|
||||
Self(u32::from(val) << 16)
|
||||
}
|
||||
|
||||
pub fn parse_raw(val: u32) -> Self {
|
||||
Self(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FpU16> for f64 {
|
||||
fn from(fp: FpU16) -> Self {
|
||||
f64::from(fp.0) / f64::from(1u32 << 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// An unsigned 8.8-bit fixed point value.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct FpU8(u16);
|
||||
|
||||
impl FpU8 {
|
||||
pub fn new(val: u8) -> Self {
|
||||
Self(u16::from(val) << 8)
|
||||
}
|
||||
|
||||
pub fn parse_raw(val: u16) -> Self {
|
||||
Self(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FpU8> for f64 {
|
||||
fn from(fp: FpU8) -> Self {
|
||||
f64::from(fp.0) / f64::from(1u16 << 8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FpU8> for f32 {
|
||||
fn from(fp: FpU8) -> Self {
|
||||
f32::from(fp.0) / f32::from(1u16 << 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// An unsigned 8.8-bit fixed point value.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct FpI8(i16);
|
||||
|
||||
impl FpI8 {
|
||||
pub fn new(val: i8) -> Self {
|
||||
Self(i16::from(val) * 0x100)
|
||||
}
|
||||
|
||||
pub fn parse_raw(val: i16) -> Self {
|
||||
Self(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FpI8> for f64 {
|
||||
fn from(fp: FpI8) -> Self {
|
||||
f64::from(fp.0) / f64::from(1u16 << 8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FpI8> for f32 {
|
||||
fn from(fp: FpI8) -> Self {
|
||||
f32::from(fp.0) / f32::from(1u16 << 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
#![warn(rust_2018_idioms)]
|
||||
// The following lints are allowed in all Symphonia crates. Please see clippy.toml for their
|
||||
// justification.
|
||||
#![allow(clippy::comparison_chain)]
|
||||
#![allow(clippy::excessive_precision)]
|
||||
#![allow(clippy::identity_op)]
|
||||
#![allow(clippy::manual_range_contains)]
|
||||
|
||||
mod atoms;
|
||||
mod demuxer;
|
||||
mod fourcc;
|
||||
mod fp;
|
||||
mod stream;
|
||||
|
||||
pub use demuxer::IsoMp4Reader;
|
||||
@@ -0,0 +1,444 @@
|
||||
// Symphonia
|
||||
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
use symphonia_core::errors::{decode_error, Error, Result};
|
||||
|
||||
use crate::atoms::{stsz::SampleSize, Co64Atom, MoofAtom, MoovAtom, MvexAtom, StcoAtom, TrafAtom};
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sample data information.
|
||||
pub struct SampleDataDesc {
|
||||
/// The starting byte position within the media data of the group of samples that contains the
|
||||
/// sample described.
|
||||
pub base_pos: u64,
|
||||
/// The offset relative to the base position of the sample described.
|
||||
pub offset: Option<u64>,
|
||||
/// The size of the sample.
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
/// Timing information for one sample.
|
||||
pub struct SampleTiming {
|
||||
/// The timestamp of the sample.
|
||||
pub ts: u64,
|
||||
/// The duration of the sample.
|
||||
pub dur: u32,
|
||||
}
|
||||
|
||||
pub trait StreamSegment: Send + Sync {
|
||||
/// Gets the sequence number of this segment.
|
||||
fn sequence_num(&self) -> u32;
|
||||
|
||||
/// Gets the first and last sample numbers for the track `track_num`.
|
||||
fn track_sample_range(&self, track_num: usize) -> Range<u32>;
|
||||
|
||||
/// Gets the first and last sample timestamps for the track `track_num`.
|
||||
fn track_ts_range(&self, track_num: usize) -> Range<u64>;
|
||||
|
||||
/// Get the timestamp and duration for the sample indicated by `sample_num` for the track
|
||||
/// `track_num`.
|
||||
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>>;
|
||||
|
||||
/// Get the sample number of the sample containing the timestamp indicated by `ts` for track
|
||||
// `track_num`.
|
||||
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>>;
|
||||
|
||||
/// Get the byte position of the group of samples containing the sample indicated by
|
||||
/// `sample_num` for track `track_num`, and it's size.
|
||||
///
|
||||
/// Optionally, the offset of the sample relative to the aforementioned byte position can be
|
||||
/// returned.
|
||||
fn sample_data(
|
||||
&self,
|
||||
track_num: usize,
|
||||
sample_num: u32,
|
||||
get_offset: bool,
|
||||
) -> Result<SampleDataDesc>;
|
||||
}
|
||||
|
||||
/// Track-to-stream sequencing information.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
struct SequenceInfo {
|
||||
/// The sample number of the first sample of a track in a fragment.
|
||||
first_sample: u32,
|
||||
/// The timestamp of the first sample of a track in a fragment.
|
||||
first_ts: u64,
|
||||
/// The total duration of all samples of a track in a fragment.
|
||||
total_sample_duration: u64,
|
||||
/// The total sample count of a track in a fragment.
|
||||
total_sample_count: u32,
|
||||
/// If present in the moof segment, this is the index of the track fragment atom for the track
|
||||
/// this sequence information is associated with.
|
||||
traf_idx: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct MoofSegment {
|
||||
moof: MoofAtom,
|
||||
mvex: Arc<MvexAtom>,
|
||||
seq: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
impl MoofSegment {
|
||||
/// Instantiate a new segment from a `MoofAtom`.
|
||||
pub fn new(moof: MoofAtom, mvex: Arc<MvexAtom>, prev: &dyn StreamSegment) -> MoofSegment {
|
||||
let mut seq = Vec::with_capacity(mvex.trexs.len());
|
||||
|
||||
// Calculate the sequence information for each track, even if not present in the fragment.
|
||||
for (track_num, trex) in mvex.trexs.iter().enumerate() {
|
||||
let mut info = SequenceInfo {
|
||||
first_sample: prev.track_sample_range(track_num).end,
|
||||
first_ts: prev.track_ts_range(track_num).end,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Find the track fragment for the track.
|
||||
for (traf_idx, traf) in moof.trafs.iter().enumerate() {
|
||||
if trex.track_id != traf.tfhd.track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate the total duration of all runs in the fragment for the track.
|
||||
let default_dur =
|
||||
traf.tfhd.default_sample_duration.unwrap_or(trex.default_sample_duration);
|
||||
|
||||
for trun in traf.truns.iter() {
|
||||
info.total_sample_duration += trun.total_duration(default_dur);
|
||||
}
|
||||
|
||||
info.total_sample_count = traf.total_sample_count;
|
||||
info.traf_idx = Some(traf_idx);
|
||||
}
|
||||
|
||||
seq.push(info);
|
||||
}
|
||||
|
||||
MoofSegment { moof, mvex, seq }
|
||||
}
|
||||
|
||||
/// Try to get the Track Fragment atom associated with the track identified by `track_num`.
|
||||
fn try_get_traf(&self, track_num: usize) -> Option<&TrafAtom> {
|
||||
debug_assert!(track_num < self.seq.len());
|
||||
self.seq[track_num].traf_idx.map(|idx| &self.moof.trafs[idx])
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamSegment for MoofSegment {
|
||||
fn sequence_num(&self) -> u32 {
|
||||
self.moof.mfhd.sequence_number
|
||||
}
|
||||
|
||||
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
|
||||
// Get the track fragment associated with track_num.
|
||||
let traf = match self.try_get_traf(track_num) {
|
||||
Some(traf) => traf,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
|
||||
let mut trun_ts_offset = self.seq[track_num].first_ts;
|
||||
|
||||
let default_dur = traf
|
||||
.tfhd
|
||||
.default_sample_duration
|
||||
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
|
||||
|
||||
for trun in traf.truns.iter() {
|
||||
// If the sample is contained within the this track run, get the timing of of the
|
||||
// sample.
|
||||
if sample_num_rel < trun.sample_count {
|
||||
let (ts, dur) = trun.sample_timing(sample_num_rel, default_dur);
|
||||
return Ok(Some(SampleTiming { ts: trun_ts_offset + ts, dur }));
|
||||
}
|
||||
|
||||
let trun_dur = trun.total_duration(default_dur);
|
||||
|
||||
sample_num_rel -= trun.sample_count;
|
||||
trun_ts_offset += trun_dur;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
|
||||
// Get the track fragment associated with track_num.
|
||||
let traf = match self.try_get_traf(track_num) {
|
||||
Some(traf) => traf,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let mut sample_num = self.seq[track_num].first_sample;
|
||||
let mut ts_accum = self.seq[track_num].first_ts;
|
||||
|
||||
let default_dur = traf
|
||||
.tfhd
|
||||
.default_sample_duration
|
||||
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
|
||||
|
||||
for trun in traf.truns.iter() {
|
||||
// Get the total duration of this track run.
|
||||
let trun_dur = trun.total_duration(default_dur);
|
||||
|
||||
// If the timestamp after the track run is greater than the desired timestamp, then the
|
||||
// desired sample must be in this run of samples.
|
||||
if ts_accum + trun_dur > ts {
|
||||
sample_num += trun.ts_sample(ts - ts_accum, default_dur);
|
||||
return Ok(Some(sample_num));
|
||||
}
|
||||
|
||||
sample_num += trun.sample_count;
|
||||
ts_accum += trun_dur;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn sample_data(
|
||||
&self,
|
||||
track_num: usize,
|
||||
sample_num: u32,
|
||||
get_offset: bool,
|
||||
) -> Result<SampleDataDesc> {
|
||||
// Get the track fragment associated with track_num.
|
||||
let traf = self.try_get_traf(track_num).unwrap();
|
||||
|
||||
// If an explicit anchor-point is set, then use that for the position, otherwise use the
|
||||
// first-byte of the enclosing moof atom.
|
||||
let traf_base_pos = match traf.tfhd.base_data_offset {
|
||||
Some(pos) => pos,
|
||||
_ => self.moof.moof_base_pos,
|
||||
};
|
||||
|
||||
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
|
||||
let mut trun_offset = traf_base_pos;
|
||||
|
||||
let default_size =
|
||||
traf.tfhd.default_sample_size.unwrap_or(self.mvex.trexs[track_num].default_sample_size);
|
||||
|
||||
for trun in traf.truns.iter() {
|
||||
// If a data offset is present for this track fragment run, then calculate the new base
|
||||
// position for the run. When a data offset is not present, do nothing because this run
|
||||
// follows the previous run.
|
||||
if let Some(offset) = trun.data_offset {
|
||||
// The offset for the run is relative to the anchor-point defined in the track
|
||||
// fragment header.
|
||||
trun_offset = if offset.is_negative() {
|
||||
traf_base_pos - u64::from(offset.wrapping_abs() as u32)
|
||||
}
|
||||
else {
|
||||
traf_base_pos + offset as u64
|
||||
};
|
||||
}
|
||||
|
||||
if sample_num_rel < trun.sample_count {
|
||||
let (offset, size) = if get_offset {
|
||||
// Get the size and offset of the sample.
|
||||
let (offset, size) = trun.sample_offset(sample_num_rel, default_size);
|
||||
(Some(offset), size)
|
||||
}
|
||||
else {
|
||||
// Just get the size of the sample.
|
||||
let size = trun.sample_size(sample_num_rel, default_size);
|
||||
(None, size)
|
||||
};
|
||||
|
||||
return Ok(SampleDataDesc { base_pos: trun_offset, size, offset });
|
||||
}
|
||||
|
||||
// Get the total size of the track fragment run.
|
||||
let trun_size = trun.total_size(default_size);
|
||||
|
||||
sample_num_rel -= trun.sample_count;
|
||||
trun_offset += trun_size;
|
||||
}
|
||||
|
||||
decode_error("isomp4: invalid sample index")
|
||||
}
|
||||
|
||||
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
|
||||
debug_assert!(track_num < self.seq.len());
|
||||
|
||||
let track = &self.seq[track_num];
|
||||
track.first_sample..track.first_sample + track.total_sample_count
|
||||
}
|
||||
|
||||
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
|
||||
debug_assert!(track_num < self.seq.len());
|
||||
|
||||
let track = &self.seq[track_num];
|
||||
track.first_ts..track.first_ts + track.total_sample_duration
|
||||
}
|
||||
}
|
||||
|
||||
fn get_chunk_offset(
|
||||
stco: &Option<StcoAtom>,
|
||||
co64: &Option<Co64Atom>,
|
||||
chunk: usize,
|
||||
) -> Result<Option<u64>> {
|
||||
// Get the offset from either the stco or co64 atoms.
|
||||
if let Some(stco) = stco.as_ref() {
|
||||
// 32-bit offset
|
||||
if let Some(offset) = stco.chunk_offsets.get(chunk) {
|
||||
Ok(Some(u64::from(*offset)))
|
||||
}
|
||||
else {
|
||||
decode_error("isomp4: missing stco entry")
|
||||
}
|
||||
}
|
||||
else if let Some(co64) = co64.as_ref() {
|
||||
// 64-bit offset
|
||||
if let Some(offset) = co64.chunk_offsets.get(chunk) {
|
||||
Ok(Some(*offset))
|
||||
}
|
||||
else {
|
||||
decode_error("isomp4: missing co64 entry")
|
||||
}
|
||||
}
|
||||
else {
|
||||
// This should never happen because it is mandatory to have either a stco or co64 atom.
|
||||
decode_error("isomp4: missing stco or co64 atom")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoovSegment {
|
||||
moov: MoovAtom,
|
||||
}
|
||||
|
||||
impl MoovSegment {
|
||||
/// Instantiate a segment from the provide moov atom.
|
||||
pub fn new(moov: MoovAtom) -> MoovSegment {
|
||||
MoovSegment { moov }
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamSegment for MoovSegment {
|
||||
fn sequence_num(&self) -> u32 {
|
||||
// The segment defined by the moov atom is always 0.
|
||||
0
|
||||
}
|
||||
|
||||
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
|
||||
// Get the trak atom associated with track_num.
|
||||
debug_assert!(track_num < self.moov.traks.len());
|
||||
|
||||
let trak = &self.moov.traks[track_num];
|
||||
|
||||
// Find the sample timing. Note, complexity of O(N).
|
||||
let timing = trak.mdia.minf.stbl.stts.find_timing_for_sample(sample_num);
|
||||
|
||||
if let Some((ts, dur)) = timing {
|
||||
Ok(Some(SampleTiming { ts, dur }))
|
||||
}
|
||||
else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
|
||||
// Get the trak atom associated with track_num.
|
||||
debug_assert!(track_num < self.moov.traks.len());
|
||||
|
||||
let trak = &self.moov.traks[track_num];
|
||||
|
||||
// Find the sample timestamp. Note, complexity of O(N).
|
||||
Ok(trak.mdia.minf.stbl.stts.find_sample_for_timestamp(ts))
|
||||
}
|
||||
|
||||
fn sample_data(
|
||||
&self,
|
||||
track_num: usize,
|
||||
sample_num: u32,
|
||||
get_offset: bool,
|
||||
) -> Result<SampleDataDesc> {
|
||||
// Get the trak atom associated with track_num.
|
||||
debug_assert!(track_num < self.moov.traks.len());
|
||||
|
||||
let trak = &self.moov.traks[track_num];
|
||||
|
||||
// Get the constituent tables.
|
||||
let stsz = &trak.mdia.minf.stbl.stsz;
|
||||
let stsc = &trak.mdia.minf.stbl.stsc;
|
||||
let stco = &trak.mdia.minf.stbl.stco;
|
||||
let co64 = &trak.mdia.minf.stbl.co64;
|
||||
|
||||
// Find the sample-to-chunk mapping. Note, complexity of O(log N).
|
||||
let group = stsc
|
||||
.find_entry_for_sample(sample_num)
|
||||
.ok_or(Error::DecodeError("invalid sample index"))?;
|
||||
|
||||
// Index of the sample relative to the chunk group.
|
||||
let sample_in_group = sample_num - group.first_sample;
|
||||
|
||||
// Index of the chunk containing the sample relative to the chunk group.
|
||||
let chunk_in_group = sample_in_group / group.samples_per_chunk;
|
||||
|
||||
// Index of the chunk containing the sample relative to the entire stream.
|
||||
let chunk_in_stream = group.first_chunk + chunk_in_group;
|
||||
|
||||
// Get the byte position of the first sample of the chunk containing the sample.
|
||||
let base_pos = get_chunk_offset(stco, co64, chunk_in_stream as usize)?.unwrap();
|
||||
|
||||
// Determine the absolute sample byte position if requested by calculating the offset of
|
||||
// the sample from the base position of the chunk.
|
||||
let offset = if get_offset {
|
||||
// Index of the sample relative to the chunk containing the sample.
|
||||
let sample_in_chunk = sample_in_group - (chunk_in_group * group.samples_per_chunk);
|
||||
|
||||
// Calculat the byte offset of the sample relative to the chunk containing it.
|
||||
let offset = match stsz.sample_sizes {
|
||||
SampleSize::Constant(size) => {
|
||||
// Constant size samples can be calculated directly.
|
||||
u64::from(sample_in_chunk) * u64::from(size)
|
||||
}
|
||||
SampleSize::Variable(ref entries) => {
|
||||
// For variable size samples, sum the sizes of all the samples preceeding the
|
||||
// desired sample in the chunk.
|
||||
let chunk_first_sample = (sample_num - sample_in_chunk) as usize;
|
||||
|
||||
if let Some(samples) = entries.get(chunk_first_sample..sample_num as usize) {
|
||||
samples.iter().map(|&size| u64::from(size)).sum()
|
||||
}
|
||||
else {
|
||||
return decode_error("isomp4: missing one or more stsz entries");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(offset)
|
||||
}
|
||||
else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the size in bytes of the sample.
|
||||
let size = match stsz.sample_sizes {
|
||||
SampleSize::Constant(size) => size,
|
||||
SampleSize::Variable(ref entries) => {
|
||||
if let Some(size) = entries.get(sample_num as usize) {
|
||||
*size
|
||||
}
|
||||
else {
|
||||
return decode_error("isomp4: missing stsz entry");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(SampleDataDesc { base_pos, size, offset })
|
||||
}
|
||||
|
||||
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
|
||||
debug_assert!(track_num < self.moov.traks.len());
|
||||
|
||||
0..self.moov.traks[track_num].mdia.minf.stbl.stsz.sample_count
|
||||
}
|
||||
|
||||
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
|
||||
debug_assert!(track_num < self.moov.traks.len());
|
||||
|
||||
0..self.moov.traks[track_num].mdia.minf.stbl.stts.total_duration
|
||||
}
|
||||
}
|
||||
+382
-50
@@ -13,7 +13,7 @@ use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream},
|
||||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
units::{self, Time},
|
||||
@@ -902,15 +902,20 @@ struct SizedDecoder {
|
||||
}
|
||||
|
||||
impl SizedDecoder {
|
||||
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||
fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
|
||||
let data_len = data.len() as u64;
|
||||
let source = SizedCursorSource {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
Default::default(),
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
|
||||
let mut hint = Hint::new();
|
||||
@@ -918,14 +923,25 @@ impl SizedDecoder {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
|
||||
// present in older iTunes-purchased M4A files.
|
||||
enable_gapless: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let meta_opts = symphonia::core::meta::MetadataOptions {
|
||||
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
|
||||
// iTunes M4A files don't choke the parser.
|
||||
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.format(&hint, mss, &format_opts, &meta_opts)
|
||||
.map_err(|e| {
|
||||
let hint_str = format_hint.unwrap_or("unknown");
|
||||
// Always print the raw Symphonia error to the terminal for diagnosis.
|
||||
eprintln!("[psysonic] probe failed (hint={hint_str}): {e}");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||||
} else {
|
||||
@@ -936,8 +952,17 @@ impl SizedDecoder {
|
||||
let track = probed.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "no playable audio track found in file".to_string())?;
|
||||
// Explicitly select only audio tracks: must have a valid codec and a
|
||||
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
|
||||
// files embed as a secondary video track.
|
||||
.find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
|
||||
"no playable audio track found in file".to_string()
|
||||
})?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
@@ -947,6 +972,7 @@ impl SizedDecoder {
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| {
|
||||
eprintln!("[psysonic] codec init failed: {e}");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
"unsupported codec: no decoder available for this audio format".to_string()
|
||||
} else {
|
||||
@@ -964,18 +990,28 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
Err(e) => return Err(format!("could not read audio data: {e}")),
|
||||
Err(e) => {
|
||||
eprintln!("[psysonic] next_packet error: {e}");
|
||||
return Err(format!("could not read audio data: {e}"));
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
if packet.track_id() != track_id {
|
||||
eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
|
||||
continue;
|
||||
}
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => break decoded,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
eprintln!("[psysonic] decode error (retry {decode_errors}): {msg}");
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("audio decode error: {e}")),
|
||||
Err(e) => {
|
||||
eprintln!("[psysonic] fatal decode error: {e}");
|
||||
return Err(format!("audio decode error: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -996,7 +1032,9 @@ impl SizedDecoder {
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||
let mss = MediaSourceStream::new(media, Default::default());
|
||||
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
|
||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||
@@ -1194,8 +1232,17 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||||
None => return GaplessInfo::default(),
|
||||
};
|
||||
|
||||
// Collect printable ASCII bytes after the tag (skip nulls / control chars)
|
||||
let tail = &data[pos + 8..data.len().min(pos + 8 + 256)];
|
||||
// In M4A/iTunes files the key is followed by a binary 'data' atom header
|
||||
// (16 bytes: size[4] + "data"[4] + type_flags[4] + locale[4]) before the
|
||||
// actual value string. Search for the " 00000000 " sentinel that every
|
||||
// iTunSMPB value starts with to locate the true start of the text.
|
||||
let search_end = data.len().min(pos + 8 + 128);
|
||||
let search_window = &data[pos + 8..search_end];
|
||||
let value_start = find_subsequence(search_window, b" 00000000 ")
|
||||
.map(|off| pos + 8 + off)
|
||||
.unwrap_or(pos + 8);
|
||||
|
||||
let tail = &data[value_start..data.len().min(value_start + 256)];
|
||||
let text: String = tail.iter()
|
||||
.map(|&b| b as char)
|
||||
.filter(|c| c.is_ascii_hexdigit() || *c == ' ')
|
||||
@@ -1254,10 +1301,11 @@ fn build_source(
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
format_hint: Option<&str>,
|
||||
hi_res: bool,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
let decoder = SizedDecoder::new(data, format_hint)?;
|
||||
let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
@@ -1336,6 +1384,9 @@ pub(crate) struct PreloadedTrack {
|
||||
pub(crate) struct ChainedInfo {
|
||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||
url: String,
|
||||
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
||||
/// them instead of re-downloading after dropping the Sink queue.
|
||||
raw_bytes: Arc<Vec<u8>>,
|
||||
duration_secs: f64,
|
||||
replay_gain_linear: f32,
|
||||
base_volume: f32,
|
||||
@@ -1347,7 +1398,15 @@ pub(crate) struct ChainedInfo {
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
||||
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
|
||||
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||
pub stream_sample_rate: Arc<AtomicU32>,
|
||||
/// The rate the device was opened at on cold start — used to restore the
|
||||
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||
pub device_default_rate: u32,
|
||||
/// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
|
||||
/// re-open the output device. `is_hi_res` controls thread-priority escalation.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
@@ -1408,25 +1467,90 @@ impl AudioCurrent {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
/// Open the system default output device at `desired_rate` Hz (0 = device default).
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Exact rate match in the device's supported config ranges.
|
||||
/// 2. Highest available rate (for hardware that doesn't support the source rate).
|
||||
/// 3. Device default.
|
||||
/// 4. System default (last resort).
|
||||
///
|
||||
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
|
||||
fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
// Request a larger audio buffer from PipeWire/PulseAudio to reduce ALSA underruns.
|
||||
// Only set if the user hasn't already configured these themselves.
|
||||
// PIPEWIRE_LATENCY: 4096 frames / 48000 Hz ≈ 85 ms — enough to absorb scheduler jitter.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
if let Some(device) = host.default_output_device() {
|
||||
if desired_rate > 0 {
|
||||
if let Ok(supported) = device.supported_output_configs() {
|
||||
let configs: Vec<_> = supported.collect();
|
||||
|
||||
// 1. Exact rate match — prefer more channels (stereo > mono).
|
||||
let exact = configs.iter()
|
||||
.filter(|c| {
|
||||
c.min_sample_rate().0 <= desired_rate
|
||||
&& desired_rate <= c.max_sample_rate().0
|
||||
})
|
||||
.max_by_key(|c| c.channels());
|
||||
|
||||
if let Some(cfg) = exact {
|
||||
let config = cfg.clone()
|
||||
.with_sample_rate(rodio::cpal::SampleRate(desired_rate));
|
||||
if let Ok((stream, handle)) =
|
||||
rodio::OutputStream::try_from_device_config(&device, config)
|
||||
{
|
||||
eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||
return (stream, handle, desired_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No exact match — use the highest supported rate.
|
||||
let best = configs.iter()
|
||||
.max_by_key(|c| c.max_sample_rate().0);
|
||||
|
||||
if let Some(cfg) = best {
|
||||
let rate = cfg.max_sample_rate().0;
|
||||
let config = cfg.clone()
|
||||
.with_sample_rate(rodio::cpal::SampleRate(rate));
|
||||
if let Ok((stream, handle)) =
|
||||
rodio::OutputStream::try_from_device_config(&device, config)
|
||||
{
|
||||
eprintln!(
|
||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||
rate, desired_rate
|
||||
);
|
||||
return (stream, handle, rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
||||
|
||||
// 3. Device default.
|
||||
if let Ok((stream, handle)) = rodio::OutputStream::try_from_device(&device) {
|
||||
let rate = device
|
||||
.default_output_config()
|
||||
.map(|c| c.sample_rate().0)
|
||||
.unwrap_or(44100);
|
||||
eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||
return (stream, handle, rate);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Last resort: system default.
|
||||
eprintln!("[psysonic] audio stream falling back to system default");
|
||||
let (stream, handle) = rodio::OutputStream::try_default()
|
||||
.expect("cannot open any audio output device");
|
||||
let rate = rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate().0)
|
||||
.unwrap_or(44100);
|
||||
(stream, handle, rate)
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// macOS: request a smaller CoreAudio buffer to reduce output latency.
|
||||
// Smaller buffers = lower latency between decoded samples and DAC output,
|
||||
// which tightens the gap between actual audio and UI event delivery.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
|
||||
@@ -1434,21 +1558,75 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Channels: main thread ←→ audio-stream thread.
|
||||
// init_tx/rx : (OutputStreamHandle, actual_rate) sent once at startup.
|
||||
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
||||
let (init_tx, init_rx) =
|
||||
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
|
||||
let (reopen_tx, reopen_rx) =
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
.spawn(move || match rodio::OutputStream::try_default() {
|
||||
Ok((_stream, handle)) => {
|
||||
tx.send(handle).ok();
|
||||
loop { std::thread::park(); }
|
||||
.spawn(move || {
|
||||
// Set PipeWire / PulseAudio latency hints before the first open.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
||||
}
|
||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
||||
}
|
||||
}
|
||||
|
||||
// Thread priority is kept at default during standard-mode playback.
|
||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||
let (mut _stream, handle, rate) = open_stream_for_rate(0);
|
||||
init_tx.send((handle, rate)).ok();
|
||||
|
||||
// Keep the stream alive and handle sample-rate switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max
|
||||
).ok();
|
||||
}
|
||||
|
||||
drop(_stream); // close old stream before opening new one
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
|
||||
// get the same wall-clock quantum as PipeWire.
|
||||
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate);
|
||||
_stream = new_stream;
|
||||
reply_tx.send(new_handle).ok();
|
||||
}
|
||||
Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let stream_handle = rx.recv().expect("audio stream handle");
|
||||
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(stream_handle),
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||
device_default_rate: initial_rate,
|
||||
stream_reopen_tx: reopen_tx,
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
@@ -1463,6 +1641,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
@@ -1494,6 +1673,32 @@ pub struct ProgressPayload {
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subsonic `buildStreamUrl()` uses a fresh random salt on every call, so two
|
||||
/// URLs for the same track differ in `t`/`s` query params. Compare a stable key.
|
||||
fn playback_identity(url: &str) -> Option<String> {
|
||||
if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
return Some(format!("local:{path}"));
|
||||
}
|
||||
if !url.contains("stream.view") {
|
||||
return None;
|
||||
}
|
||||
let q = url.split('?').nth(1)?;
|
||||
for pair in q.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("id=") {
|
||||
let v = v.split('&').next().unwrap_or(v);
|
||||
return Some(format!("stream:{v}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||
match (playback_identity(a_url), playback_identity(b_url)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => a_url == b_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch track bytes from the preload cache or via HTTP.
|
||||
async fn fetch_data(
|
||||
url: &str,
|
||||
@@ -1504,7 +1709,7 @@ async fn fetch_data(
|
||||
// Check preload cache first.
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
@@ -1575,6 +1780,7 @@ pub async fn audio_play(
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -1602,10 +1808,14 @@ pub async fn audio_play(
|
||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||
// advance. The source is live in the queue — just return and let the
|
||||
// progress task handle the state transition when the previous source ends.
|
||||
if gapless {
|
||||
//
|
||||
// Never for manual skips: the UI already jumped to this track in JS, but
|
||||
// the current source is still playing until the chain drains. User-initiated
|
||||
// play must clear the chain and start this URL immediately (standard path).
|
||||
if gapless && !manual {
|
||||
let already_chained = state.chained_info.lock().unwrap()
|
||||
.as_ref()
|
||||
.map(|c| c.url == url)
|
||||
.map(|c| same_playback_target(&c.url, &url))
|
||||
.unwrap_or(false);
|
||||
if already_chained {
|
||||
return Ok(());
|
||||
@@ -1616,18 +1826,40 @@ pub async fn audio_play(
|
||||
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
||||
// proactive chain was not set up in time.
|
||||
|
||||
// Bump generation first so the old progress task stops before we peel
|
||||
// chained_info (avoids a race where it sees current_done + empty chain).
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any pending chain (manual skip while gapless chain was set up).
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
|
||||
// preload cache was already consumed when the chain was built). Otherwise
|
||||
// clear any stale chain metadata.
|
||||
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
|
||||
let mut ci = state.chained_info.lock().unwrap();
|
||||
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
ci.take().map(|info| {
|
||||
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
|
||||
})
|
||||
} else {
|
||||
*ci = None;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
None
|
||||
};
|
||||
|
||||
// Stop fading-out sink from previous crossfade.
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Fetch bytes (may use preload cache).
|
||||
let data = match fetch_data(&url, &state, gen, &app).await? {
|
||||
// Fetch bytes (preload cache) unless we reused the chained download above.
|
||||
let data = if let Some(d) = reuse_chained_bytes {
|
||||
Some(d)
|
||||
} else {
|
||||
fetch_data(&url, &state, gen, &app).await?
|
||||
};
|
||||
let data = match data {
|
||||
Some(d) => d,
|
||||
None => return Ok(()), // superseded while downloading
|
||||
};
|
||||
@@ -1687,6 +1919,7 @@ pub async fn audio_play(
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
@@ -1701,9 +1934,63 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
// toggle was just turned off mid-session), restore the device
|
||||
// default rate so playback is no longer at 88.2/96 kHz etc.
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, reply_tx)).is_ok() {
|
||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(new_handle) => {
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||
// At sample rates > 48 kHz the hardware quantum is larger and the first
|
||||
// period demands more decoded frames than at 44.1/48 kHz.
|
||||
// Strategy: pause the sink before appending so rodio's internal mixer
|
||||
// decodes into its ring buffer ahead of the hardware. After a short delay
|
||||
// we resume — the buffer is already full and the hardware gets its frames
|
||||
// without an underrun on the very first period.
|
||||
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
|
||||
let needs_prefill = hi_res_enabled && output_rate > 48_000;
|
||||
if needs_prefill {
|
||||
sink.pause();
|
||||
}
|
||||
|
||||
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
||||
// Only when this is an auto-advance (near end), not on manual skip.
|
||||
if !gapless {
|
||||
@@ -1727,6 +2014,19 @@ pub async fn audio_play(
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
// 500 ms lets rodio decode several seconds of hi-res audio into its
|
||||
// internal buffer while the sink is paused. The hardware sees no gap
|
||||
// because the output is held — it only starts draining after sink.play().
|
||||
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
|
||||
// absorbing scheduler jitter and PipeWire graph wake-up latency.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
sink.play();
|
||||
}
|
||||
|
||||
// Atomically swap sinks — extract old sink + its fade-out trigger.
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -1813,12 +2113,13 @@ pub async fn audio_chain_preload(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
hi_res_enabled: bool,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Idempotent: already chained this URL → nothing to do.
|
||||
// Idempotent: already chained this track → nothing to do.
|
||||
{
|
||||
let chained = state.chained_info.lock().unwrap();
|
||||
if chained.as_ref().map(|c| c.url == url).unwrap_or(false) {
|
||||
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -1834,7 +2135,7 @@ pub async fn audio_chain_preload(
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
@@ -1870,6 +2171,8 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let raw_bytes = Arc::new(data);
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
@@ -1882,7 +2185,7 @@ pub async fn audio_chain_preload(
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
data,
|
||||
(*raw_bytes).clone(),
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
@@ -1892,6 +2195,7 @@ pub async fn audio_chain_preload(
|
||||
chain_counter.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
@@ -1901,6 +2205,25 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
{
|
||||
let cur = state.current.lock().unwrap();
|
||||
@@ -1915,6 +2238,7 @@ pub async fn audio_chain_preload(
|
||||
|
||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||
url,
|
||||
raw_bytes,
|
||||
duration_secs,
|
||||
replay_gain_linear: gain_linear,
|
||||
base_volume: volume.clamp(0.0, 1.0),
|
||||
@@ -2303,10 +2627,18 @@ pub async fn audio_preload(
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
@@ -2439,7 +2771,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(counting);
|
||||
|
||||
|
||||
+269
-26
@@ -1,10 +1,8 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// To enable this feature:
|
||||
/// 1. Go to https://discord.com/developers/applications and create an application.
|
||||
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
|
||||
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
|
||||
/// (use the app icon from public/logo.png).
|
||||
/// Album artwork is fetched from the iTunes Search API and passed directly to
|
||||
/// Discord via the large_image URL field. This avoids the need to pre-upload
|
||||
/// assets to the Discord Developer Portal.
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
@@ -13,15 +11,201 @@ use discord_rich_presence::{
|
||||
activity::{Activity, ActivityType, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
|
||||
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
|
||||
pub struct ArtworkCacheEntry {
|
||||
pub url: String,
|
||||
pub fetched_at: Instant,
|
||||
}
|
||||
|
||||
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
|
||||
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
pub struct DiscordState {
|
||||
pub client: Mutex<Option<DiscordIpcClient>>,
|
||||
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
|
||||
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
|
||||
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
|
||||
pub http_client: Client,
|
||||
}
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState(Mutex::new(None))
|
||||
DiscordState {
|
||||
client: Mutex::new(None),
|
||||
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
http_client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── iTunes Search API ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResponse {
|
||||
results: Vec<ItunesResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResult {
|
||||
collectionName: Option<String>,
|
||||
artistName: Option<String>,
|
||||
artworkUrl100: Option<String>,
|
||||
}
|
||||
|
||||
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Search for album artwork via iTunes Search API.
|
||||
/// Returns a higher-resolution URL (600x600) if found.
|
||||
///
|
||||
/// Takes explicit `client` and `cache` so this can be called from inside
|
||||
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
|
||||
fn search_itunes_artwork(
|
||||
client: &Client,
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
title: &str,
|
||||
) -> Option<String> {
|
||||
let cache_key = format!("{}|{}", artist, album);
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let c = cache.lock().ok()?;
|
||||
if let Some(entry) = c.get(&cache_key) {
|
||||
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
|
||||
return Some(entry.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let norm_artist = normalize(artist);
|
||||
let norm_album = normalize(album);
|
||||
let norm_title = normalize(title);
|
||||
|
||||
// Strategy 1: exact match search — "artist" "album"
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "5");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 2: relaxed search — artist album (no quotes)
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 3: search by track title — artist + title (for singles/rare albums)
|
||||
if !title.is_empty() {
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, title))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "song")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn search_with_url(
|
||||
client: &Client,
|
||||
url: url::Url,
|
||||
norm_artist: &str,
|
||||
norm_album: &str,
|
||||
) -> Option<String> {
|
||||
let resp = client.get(url).send().ok()?;
|
||||
let body: ItunesResponse = resp.json().ok()?;
|
||||
|
||||
for result in &body.results {
|
||||
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
|
||||
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
|
||||
|
||||
// Flexible matching: check if strings contain each other
|
||||
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
|
||||
let artist_match = norm_artist == result_artist
|
||||
|| norm_artist.contains(&result_artist)
|
||||
|| result_artist.contains(&norm_artist)
|
||||
|| words_overlap(norm_artist, &result_artist);
|
||||
|
||||
let album_match = norm_album == collection
|
||||
|| norm_album.contains(&collection)
|
||||
|| collection.contains(norm_album)
|
||||
|| words_overlap(norm_album, &collection);
|
||||
|
||||
if artist_match && album_match {
|
||||
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if two strings share at least 50% of their words.
|
||||
fn words_overlap(a: &str, b: &str) -> bool {
|
||||
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
|
||||
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
|
||||
|
||||
if words_a.is_empty() || words_b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let common = words_a.intersection(&words_b).count();
|
||||
let min_len = words_a.len().min(words_b.len());
|
||||
|
||||
common >= min_len / 2 + min_len % 2 // At least 50% overlap
|
||||
}
|
||||
|
||||
fn cache_and_return(
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
key: String,
|
||||
url: &str,
|
||||
) {
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(
|
||||
key,
|
||||
ArtworkCacheEntry {
|
||||
url: url.to_string(),
|
||||
fetched_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,17 +218,50 @@ fn try_connect() -> Option<DiscordIpcClient> {
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
|
||||
/// the song/artist without a running timer.
|
||||
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — no timestamp is sent so
|
||||
/// Discord stops any running timer.
|
||||
/// - `cover_art_url`: optional direct URL to album artwork.
|
||||
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
|
||||
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
|
||||
/// without making any external request — required for privacy opt-in.
|
||||
#[tauri::command]
|
||||
pub fn discord_update_presence(
|
||||
state: tauri::State<DiscordState>,
|
||||
pub async fn discord_update_presence(
|
||||
state: tauri::State<'_, DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
is_playing: bool,
|
||||
elapsed_secs: Option<f64>,
|
||||
cover_art_url: Option<String>,
|
||||
fetch_itunes_covers: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
|
||||
// run on the Tokio async executor directly.
|
||||
// Only hit the iTunes API if the user has explicitly opted in.
|
||||
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
|
||||
Some(url)
|
||||
} else if fetch_itunes_covers {
|
||||
if let Some(ref album_name) = album {
|
||||
let http_client = state.http_client.clone();
|
||||
let cache = Arc::clone(&state.artwork_cache);
|
||||
let artist_c = artist.clone();
|
||||
let album_c = album_name.clone();
|
||||
let title_c = title.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
@@ -62,25 +279,51 @@ pub fn discord_update_presence(
|
||||
// the cover art icon.
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
|
||||
let assets = Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text);
|
||||
let assets = if let Some(ref url) = artwork_url {
|
||||
Assets::new()
|
||||
.large_image(url.as_str())
|
||||
.large_text(large_text)
|
||||
} else {
|
||||
// Fallback to default Psysonic icon
|
||||
Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text)
|
||||
};
|
||||
|
||||
// When paused, show "Paused" as the state text (replaces artist name).
|
||||
let state_text: String = if is_playing {
|
||||
artist.clone()
|
||||
} else {
|
||||
"Paused".to_string()
|
||||
};
|
||||
|
||||
// ActivityType::Listening causes the Discord client to auto-start a running
|
||||
// timer from "now" even when no timestamps are provided. Switch to Playing
|
||||
// when paused — Playing only shows a timer when timestamps are explicitly set.
|
||||
let activity_type = if is_playing {
|
||||
ActivityType::Listening
|
||||
} else {
|
||||
ActivityType::Playing
|
||||
};
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.activity_type(activity_type)
|
||||
.details(&title)
|
||||
.state(&artist)
|
||||
.state(&state_text)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
// Only set when playing — Playing type without timestamps shows no timer.
|
||||
if is_playing {
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
}
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
@@ -95,7 +338,7 @@ pub fn discord_update_presence(
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
|
||||
+165
-45
@@ -36,49 +36,6 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Restart after an in-app update.
|
||||
///
|
||||
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
|
||||
/// is still alive, so the single-instance plugin in the new process sees the old
|
||||
/// socket and kills itself immediately (endless loop).
|
||||
///
|
||||
/// This command instead:
|
||||
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
|
||||
/// by then the old process and its single-instance socket are fully gone.
|
||||
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
|
||||
/// prevent_close() and releases the single-instance lock immediately.
|
||||
#[tauri::command]
|
||||
fn relaunch_after_update(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
|
||||
let script = format!(
|
||||
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
|
||||
let bundle = exe
|
||||
.parent() // MacOS/
|
||||
.and_then(|p| p.parent()) // Contents/
|
||||
.and_then(|p| p.parent()); // Psysonic.app
|
||||
if let Some(bundle_path) = bundle {
|
||||
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
@@ -578,6 +535,167 @@ async fn delete_offline_track(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hot playback cache (ephemeral; queue-based prefetch) ─────────────────────
|
||||
|
||||
fn resolve_hot_cache_root(
|
||||
custom_dir: Option<String>,
|
||||
app: &tauri::AppHandle,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) {
|
||||
let base = std::path::PathBuf::from(cd);
|
||||
if !base.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
Ok(base.join("psysonic-hot-cache"))
|
||||
} else {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-hot-cache"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HotCacheDownloadResult {
|
||||
path: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Downloads a single track into the hot playback cache (separate from offline library).
|
||||
/// Optional `custom_dir`: parent folder; files go under `<custom_dir>/psysonic-hot-cache/<server_id>/`.
|
||||
/// Returns absolute path and file size for `psysonic-local://` URLs.
|
||||
#[tauri::command]
|
||||
async fn download_track_hot_cache(
|
||||
track_id: String,
|
||||
server_id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
custom_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<HotCacheDownloadResult, String> {
|
||||
let root = resolve_hot_cache_root(custom_dir, &app)?;
|
||||
let cache_dir = root.join(&server_id);
|
||||
|
||||
tokio::fs::create_dir_all(&cache_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
if file_path.exists() {
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
return Ok(HotCacheDownloadResult {
|
||||
path: path_str,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
tokio::fs::write(&file_path, &bytes)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let size = bytes.len() as u64;
|
||||
Ok(HotCacheDownloadResult {
|
||||
path: path_str,
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
|
||||
fn dir_size(root: std::path::PathBuf) -> u64 {
|
||||
if !root.exists() {
|
||||
return 0;
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
let mut stack = vec![root];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(meta) = std::fs::metadata(&path) {
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
resolve_hot_cache_root(custom_dir, &app)
|
||||
.map(|root| dir_size(root))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_hot_cache_track(
|
||||
local_path: String,
|
||||
custom_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let file_path = std::path::PathBuf::from(&local_path);
|
||||
if file_path.exists() {
|
||||
tokio::fs::remove_file(&file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
|
||||
|
||||
let mut current = file_path.parent().map(|p| p.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
if dir == boundary || !dir.starts_with(&boundary) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(mut entries) => {
|
||||
if entries.next().is_some() {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(&dir).is_err() {
|
||||
break;
|
||||
}
|
||||
current = dir.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
|
||||
#[tauri::command]
|
||||
async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = resolve_hot_cache_root(custom_dir, &app)?;
|
||||
if dir.exists() {
|
||||
tokio::fs::remove_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds and returns a new system-tray icon with all menu items and event handlers.
|
||||
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
|
||||
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
@@ -693,7 +811,6 @@ pub fn run() {
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.manage(TrayState::default())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
@@ -866,7 +983,10 @@ pub fn run() {
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
relaunch_after_update,
|
||||
download_track_hot_cache,
|
||||
get_hot_cache_size,
|
||||
delete_hot_cache_track,
|
||||
purge_hot_cache,
|
||||
toggle_tray_icon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.3",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -16,8 +16,8 @@
|
||||
"title": "Psysonic",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"minWidth": 360,
|
||||
"minHeight": 480,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
@@ -31,14 +31,6 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
+57
-17
@@ -8,6 +8,9 @@ import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import MobilePlayerView from './components/MobilePlayerView';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||
import QueuePanel from './components/QueuePanel';
|
||||
@@ -23,6 +26,7 @@ import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import MostPlayed from './pages/MostPlayed';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
@@ -50,7 +54,9 @@ import AppUpdater from './components/AppUpdater';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders } from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -66,6 +72,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
@@ -77,9 +84,28 @@ function AppShell() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
if (!cancelled) setMusicFolders(folders);
|
||||
} catch {
|
||||
if (!cancelled) setMusicFolders([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders]);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
useEffect(() => {
|
||||
@@ -219,19 +245,25 @@ function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
)}
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
@@ -273,7 +305,8 @@ function AppShell() {
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/now-playing" element={<NowPlayingPage />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
@@ -285,16 +318,19 @@ function AppShell() {
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
<QueuePanel />
|
||||
<PlayerBar />
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && <QueuePanel />}
|
||||
{isMobile && !isMobilePlayer && <BottomNav />}
|
||||
{!isMobilePlayer && <PlayerBar />}
|
||||
{isFullscreenOpen && (
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
@@ -448,6 +484,10 @@ export default function App() {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return initHotCachePrefetch();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useGlobalShortcutsStore.getState().registerAll();
|
||||
}, []);
|
||||
|
||||
+90
-6
@@ -40,6 +40,15 @@ async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, tim
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
|
||||
export function libraryFilterParams(): Record<string, string | number> {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return {};
|
||||
const f = musicLibraryFilterByServer[activeServerId];
|
||||
if (f === undefined || f === 'all') return {};
|
||||
return { musicFolderId: f };
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
export interface SubsonicAlbum {
|
||||
id: string;
|
||||
@@ -49,6 +58,7 @@ export interface SubsonicAlbum {
|
||||
coverArt?: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
playCount?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
@@ -139,6 +149,11 @@ export interface SubsonicGenre {
|
||||
albumCount: number;
|
||||
}
|
||||
|
||||
export interface SubsonicMusicFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SubsonicArtistInfo {
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
@@ -150,6 +165,19 @@ export interface SubsonicArtistInfo {
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||
'getMusicFolders.view',
|
||||
);
|
||||
const raw = data.musicFolders?.musicFolder;
|
||||
if (!raw) return [];
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
return arr.map(f => ({
|
||||
id: String((f as { id: string | number }).id),
|
||||
name: (f as { name?: string }).name ?? 'Library',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
await api('ping.view');
|
||||
@@ -178,7 +206,11 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size });
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
size,
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
@@ -188,12 +220,19 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
offset,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
...extra,
|
||||
});
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now() };
|
||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
@@ -215,7 +254,9 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view');
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
}
|
||||
@@ -258,7 +299,12 @@ export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
|
||||
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'byGenre', genre, size, offset, _t: Date.now(),
|
||||
type: 'byGenre',
|
||||
genre,
|
||||
size,
|
||||
offset,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const raw = data.albumList2?.album;
|
||||
if (!raw) return [];
|
||||
@@ -284,7 +330,7 @@ export async function getStarred(): Promise<StarredResults> {
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view');
|
||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
@@ -318,6 +364,7 @@ export async function search(query: string, options?: { albumCount?: number; art
|
||||
artistCount: options?.artistCount ?? 5,
|
||||
albumCount: options?.albumCount ?? 5,
|
||||
songCount: options?.songCount ?? 10,
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const r = data.searchResult3 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
@@ -606,3 +653,40 @@ export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStati
|
||||
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
|
||||
return invoke<[number[], string]>('fetch_url_bytes', { url });
|
||||
}
|
||||
|
||||
// ─── Structured Lyrics (OpenSubsonic / getLyricsBySongId) ────────────────────
|
||||
|
||||
export interface SubsonicLyricLine {
|
||||
start?: number; // milliseconds — absent when unsynced
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SubsonicStructuredLyrics {
|
||||
issynced: boolean;
|
||||
lang?: string;
|
||||
offset?: number;
|
||||
displayArtist?: string;
|
||||
displayTitle?: string;
|
||||
line: SubsonicLyricLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches structured lyrics from the server's embedded tags via the
|
||||
* OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the
|
||||
* server doesn't support the endpoint or the track has no embedded lyrics.
|
||||
* Prefers synced lyrics over plain when both are present.
|
||||
*/
|
||||
export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredLyrics | null> {
|
||||
try {
|
||||
const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>(
|
||||
'getLyricsBySongId.view',
|
||||
{ id },
|
||||
);
|
||||
const list = data.lyricsList?.structuredLyrics;
|
||||
if (!list || list.length === 0) return null;
|
||||
return list.find(l => l.issynced) ?? list[0];
|
||||
} catch {
|
||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+148
-61
@@ -5,6 +5,7 @@ import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -109,6 +110,7 @@ export default function AlbumHeader({
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
@@ -184,72 +186,157 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<div className="album-detail-actions-mobile">
|
||||
{/* Row 1 — Primary actions */}
|
||||
<div className="album-actions-row album-actions-row--primary">
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--play"
|
||||
onClick={onPlayAll}
|
||||
aria-label={t('albumDetail.playAll')}
|
||||
data-tooltip={t('albumDetail.playAll')}
|
||||
>
|
||||
<Play size={24} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--queue"
|
||||
onClick={onEnqueueAll}
|
||||
aria-label={t('albumDetail.enqueue')}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
aria-label={t('albumDetail.artistBio')}
|
||||
data-tooltip={t('albumDetail.artistBio')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,19 @@ import React, { useRef, useState, useEffect } from 'react';
|
||||
import { SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
titleLink?: string;
|
||||
albums: SubsonicAlbum[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -61,7 +62,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
{titleLink ? (
|
||||
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
|
||||
{title}<ChevronRight size={18} className="section-title-chevron" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
)}
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -96,6 +97,7 @@ export default function AlbumTrackList({
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
@@ -293,6 +295,50 @@ export default function AlbumTrackList({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ─────────────────────────────────────────────────────
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="tracklist-mobile">
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span> CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const isActive = currentTrack?.id === song.id;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
<div className="tracklist-mobile-main">
|
||||
{isActive && isPlaying ? (
|
||||
<span className="tracklist-mobile-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
|
||||
)}
|
||||
<span className="tracklist-mobile-title">{song.title}</span>
|
||||
</div>
|
||||
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
|
||||
+21
-100
@@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, Download, X } from 'lucide-react';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
|
||||
@@ -18,85 +16,31 @@ function isNewer(a: string, b: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null; error?: string }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<State>({ phase: 'idle' });
|
||||
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Try Tauri native updater first (macOS + Windows)
|
||||
const update = await check();
|
||||
if (cancelled) return;
|
||||
if (update) {
|
||||
setState({ phase: 'available', version: update.version, update });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Tauri updater unavailable or network error — fall through to GitHub check
|
||||
}
|
||||
// Fallback: GitHub API check (Linux / offline Tauri updater)
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
|
||||
setNewVersion(tag.replace(/^[^0-9]*/, ''));
|
||||
}
|
||||
} catch {
|
||||
// No update check possible — stay idle
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
}, 3000);
|
||||
}, 4000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (state.phase !== 'available' || !state.update) return;
|
||||
const update = state.update;
|
||||
const savedVersion = state.version;
|
||||
let total = 0;
|
||||
let downloaded = 0;
|
||||
setState({ phase: 'downloading', pct: 0 });
|
||||
try {
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
|
||||
} else if (event.event === 'Finished') {
|
||||
setState({ phase: 'installing' });
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Update failed:', msg);
|
||||
// Surface the error so the user (and developer) can see what went wrong
|
||||
setState({ phase: 'available', version: savedVersion, update, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
open('https://github.com/Psychotoxical/psysonic/releases/latest');
|
||||
};
|
||||
|
||||
const version = state.phase === 'available' ? state.version : '';
|
||||
const canInstall = state.phase === 'available' && state.update !== null;
|
||||
const isLinuxFallback = state.phase === 'available' && state.update === null;
|
||||
if (!newVersion || dismissed) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
@@ -107,44 +51,21 @@ export default function AppUpdater() {
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
|
||||
|
||||
{state.phase === 'downloading' && (
|
||||
<div className="app-updater-progress-wrap">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{state.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'installing' && (
|
||||
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{state.error && (
|
||||
<div className="app-updater-error">{state.error}</div>
|
||||
)}
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<button className="app-updater-btn-secondary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLinuxFallback && (
|
||||
<button className="app-updater-btn-primary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
|
||||
<div className="app-updater-actions">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
className="app-updater-btn-secondary"
|
||||
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
|
||||
>
|
||||
{t('common.updaterWebsite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import MobileSearchOverlay from './MobileSearchOverlay';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
|
||||
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
|
||||
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
|
||||
] as const;
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bottom-nav" aria-label="Mobile navigation">
|
||||
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="bottom-nav-icon-wrap">
|
||||
<Icon size={22} />
|
||||
{to === '/now-playing' && isPlaying && currentTrack && (
|
||||
<span className="bottom-nav-np-dot" />
|
||||
)}
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="bottom-nav-item"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('search.title')}
|
||||
>
|
||||
<span className="bottom-nav-icon-wrap">
|
||||
<Search size={22} />
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t('search.title')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
import { extractCoverColors } from '../utils/dynamicColors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLyrics } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -15,12 +21,136 @@ 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)
|
||||
|
||||
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, loading } = useLyrics(currentTrack);
|
||||
const lines = syncedLines as LrcLine[] | null;
|
||||
const hasSynced = lines !== null && lines.length > 0;
|
||||
|
||||
// Keep a ref so the zustand selector can read lines without closing over
|
||||
// a changing variable — avoids re-creating the selector on every render.
|
||||
const linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lines! : [];
|
||||
|
||||
// Selector returns the active line INDEX — zustand only re-renders when it
|
||||
// actually changes, dropping us from ~10 Hz to ~0.2 Hz re-renders.
|
||||
const activeIdx = usePlayerStore(s => {
|
||||
const ls = linesRef.current;
|
||||
if (ls.length === 0) return -1;
|
||||
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
|
||||
});
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
|
||||
const slotH = useRef(window.innerHeight * 0.06);
|
||||
useEffect(() => {
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Event delegation — one handler for all lyric lines instead of N closures per tick.
|
||||
const handleLineClick = 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 || !hasSynced) return null;
|
||||
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fs-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fs-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
{lines!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Album art box — crossfades layers so old art stays visible while new loads ─
|
||||
// Uses 300px thumbnails (portrait fallback uses 500px separately).
|
||||
//
|
||||
// Why onLoad instead of new Image() preload:
|
||||
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
|
||||
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
|
||||
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
|
||||
// before we flip it to 1.
|
||||
const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
|
||||
// true = show raw fetchUrl immediately as fallback while blob resolves.
|
||||
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
|
||||
// Showing the URL directly avoids the multi-second blank wait.
|
||||
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
|
||||
|
||||
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Add a new invisible layer whenever the blob URL changes.
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
const id = ++counter.current;
|
||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
||||
}, [blobUrl]);
|
||||
|
||||
// Called by the DOM <img> once it has painted at opacity:0 — now safe to transition.
|
||||
// Cancel any pending cleanup timer so a stale setTimeout from a previous layer
|
||||
// cannot remove the layer we are making visible right now.
|
||||
const handleLoad = useCallback((id: number) => {
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
|
||||
}, []);
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => handleLoad(l.id)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Artist portrait — right half, crossfades on track change ─────────────────
|
||||
const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
@@ -32,8 +162,9 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
setTimeout(() => {
|
||||
cleanupTimer.current = setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 1000);
|
||||
});
|
||||
@@ -122,12 +253,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
// Derive isStarred inside the selector so we only re-render when the boolean
|
||||
// actually flips — not when any unrelated track's star status changes.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
@@ -144,11 +277,33 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
// 300px for the small art box; 500px for the right-side portrait fallback.
|
||||
const artUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]);
|
||||
const artKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]);
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Dynamic accent color extracted from the current album cover.
|
||||
// Applied as --dynamic-fs-accent on the root element so it inherits to all
|
||||
// children; CSS rules use var(--dynamic-fs-accent, var(--accent)) as fallback.
|
||||
// Reset to null on track change so the previous color doesn't linger while
|
||||
// the new one is being extracted.
|
||||
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setDynamicAccent(null);
|
||||
if (!artUrl || !artKey) return;
|
||||
let cancelled = false;
|
||||
getCachedUrl(artUrl, artKey).then(blobUrl => {
|
||||
if (cancelled || !blobUrl) return;
|
||||
extractCoverColors(blobUrl).then(colors => {
|
||||
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [artKey]); // artKey is stable per track — artUrl would also work
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
@@ -163,22 +318,72 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
|
||||
// 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.
|
||||
const nextCoverArt = usePlayerStore(s => {
|
||||
const q = s.queue;
|
||||
const idx = s.queueIndex;
|
||||
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!nextCoverArt) return;
|
||||
const url = buildCoverArtUrl(nextCoverArt, 300);
|
||||
const key = coverArtCacheKey(nextCoverArt, 300);
|
||||
getCachedUrl(url, key).catch(() => {});
|
||||
}, [nextCoverArt]);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const resetIdle = useCallback(() => {
|
||||
setIsIdle(false);
|
||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||
idleTimer.current = setTimeout(() => setIsIdle(true), 3000);
|
||||
}, []);
|
||||
|
||||
// Throttled wrapper for mousemove — avoids clearing/setting timeouts on every pixel.
|
||||
const lastMoveTime = useRef(0);
|
||||
const handleMouseMove = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastMoveTime.current < 200) return;
|
||||
lastMoveTime.current = now;
|
||||
resetIdle();
|
||||
}, [resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
resetIdle();
|
||||
return () => { if (idleTimer.current) clearTimeout(idleTimer.current); };
|
||||
}, [resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
resetIdle();
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
}, [onClose, resetIdle]);
|
||||
|
||||
const metaParts = [
|
||||
const metaParts = useMemo(() => [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean);
|
||||
].filter(Boolean), [currentTrack]);
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
@@ -197,29 +402,23 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics overlay — upper-left quadrant, above cluster */}
|
||||
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-art"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
|
||||
)}
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Artist — massive statement */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Track title — accent, light weight */}
|
||||
{/* Track title — massive statement */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Artist — secondary, below track */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
@@ -262,6 +461,15 @@ 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>
|
||||
|
||||
</div>
|
||||
|
||||
+61
-31
@@ -6,6 +6,8 @@ import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
@@ -50,6 +52,8 @@ interface HeroProps {
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -57,7 +61,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, [albumsProp]);
|
||||
}, [albumsProp, musicLibraryFilterVersion]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
@@ -120,7 +124,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
{coverRawUrl && (
|
||||
{coverRawUrl && !isMobile && (
|
||||
<CachedImage
|
||||
className="hero-cover"
|
||||
src={coverRawUrl}
|
||||
@@ -135,36 +139,62 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
<div className="hero-meta">
|
||||
{album.year && <span className="badge">{album.year}</span>}
|
||||
{album.genre && <span className="badge">{album.genre}</span>}
|
||||
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
{t('hero.playAlbum')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) { }
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
{t('hero.enqueue')}
|
||||
</button>
|
||||
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--play"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={22} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--queue"
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
aria-label={t('hero.enqueue')}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
{t('hero.playAlbum')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
{t('hero.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
@@ -24,6 +25,7 @@ export default function LiveSearch() {
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
@@ -37,7 +39,7 @@ export default function LiveSearch() {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
[musicLibraryFilterVersion]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import { useLyrics } from '../hooks/useLyrics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
@@ -8,87 +9,27 @@ interface Props {
|
||||
currentTrack: Track | null;
|
||||
}
|
||||
|
||||
interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// Session-level cache — survives tab switches (component unmount/remount).
|
||||
// Cleared implicitly when the app restarts.
|
||||
const lyricsCache = new Map<string, CachedLyrics>();
|
||||
|
||||
export default function LyricsPane({ currentTrack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const { syncedLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
|
||||
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
|
||||
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
||||
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const prevActive = useRef(-1);
|
||||
|
||||
// Reset refs when track changes
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
// Serve from cache if available
|
||||
const hit = lyricsCache.get(currentTrack.id);
|
||||
if (hit) {
|
||||
setSyncedLines(hit.syncedLines);
|
||||
setPlainLyrics(hit.plainLyrics);
|
||||
setNotFound(hit.notFound);
|
||||
setLoading(false);
|
||||
lineRefs.current = [];
|
||||
prevActive.current = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSyncedLines(null);
|
||||
setPlainLyrics(null);
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
lineRefs.current = [];
|
||||
prevActive.current = -1;
|
||||
|
||||
fetchLyrics(
|
||||
currentTrack.artist ?? '',
|
||||
currentTrack.title,
|
||||
currentTrack.album ?? '',
|
||||
currentTrack.duration ?? 0,
|
||||
).then(result => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
||||
const synced = lines && lines.length > 0 ? lines : null;
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
|
||||
setSyncedLines(synced);
|
||||
setPlainLyrics(result.plainLyrics);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
||||
setLoading(false);
|
||||
setNotFound(true);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
const activeIdx = hasSynced
|
||||
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
||||
? (syncedLines as LrcLine[]).reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,13 +53,19 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
return `${base} active`;
|
||||
};
|
||||
|
||||
const sourceLabel = source === 'server'
|
||||
? t('player.lyricsSourceServer')
|
||||
: source === 'lrclib'
|
||||
? t('player.lyricsSourceLrclib')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
||||
{hasSynced && (
|
||||
<div className="lyrics-synced">
|
||||
{syncedLines!.map((line, i) => (
|
||||
{(syncedLines as LrcLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
@@ -138,6 +85,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sourceLabel && !loading && !notFound && (
|
||||
<p className="lyrics-source">{sourceLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||||
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
|
||||
|
||||
function extractVibrantColor(imageUrl: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve('0,0,0'); return; }
|
||||
ctx.drawImage(img, 0, 0, 16, 16);
|
||||
const { data } = ctx.getImageData(0, 0, 16, 16);
|
||||
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
const max = Math.max(r, g, b) / 255;
|
||||
const min = Math.min(r, g, b) / 255;
|
||||
const l = (max + min) / 2;
|
||||
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
|
||||
// Prefer saturated pixels in the medium-dark range (l 0.2–0.6)
|
||||
const score = s * (1 - Math.abs(l - 0.4));
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestR = r; bestG = g; bestB = b;
|
||||
}
|
||||
}
|
||||
resolve(`${bestR},${bestG},${bestB}`);
|
||||
};
|
||||
img.onerror = () => resolve('0,0,0');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function useAlbumAccentColor(imageUrl: string): string {
|
||||
const [color, setColor] = useState('0,0,0');
|
||||
useEffect(() => {
|
||||
if (!imageUrl) { setColor('0,0,0'); return; }
|
||||
let cancelled = false;
|
||||
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
|
||||
return () => { cancelled = true; };
|
||||
}, [imageUrl]);
|
||||
return color;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||||
|
||||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll active track into view on open
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector('.mq-item.active');
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('queue.title')}</h3>
|
||||
<span className="mq-drawer-count">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
|
||||
</span>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list" ref={listRef}>
|
||||
{queue.length === 0 ? (
|
||||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
className={`mq-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { playTrack(track, queue); onClose(); }}
|
||||
>
|
||||
<div className="mq-item-info">
|
||||
<div className="mq-item-title">
|
||||
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
||||
<span className="truncate">{track.title}</span>
|
||||
</div>
|
||||
<div className="mq-item-artist truncate">{track.artist}</div>
|
||||
</div>
|
||||
<span className="mq-item-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('player.lyrics')}</h3>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list">
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Player View ────────────────────────────────────────────────────────
|
||||
|
||||
export default function MobilePlayerView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Lock body scroll while full-screen player is mounted
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art
|
||||
const coverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Dynamic background color extracted from cover art
|
||||
const accentColor = useAlbumAccentColor(resolvedCover);
|
||||
|
||||
// Star / favorite
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const nextVal = !isStarred;
|
||||
setStarredOverride(currentTrack.id, nextVal);
|
||||
try {
|
||||
if (nextVal) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !nextVal);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
const scrubberRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const seekFromX = useCallback((clientX: number) => {
|
||||
const el = scrubberRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seek(pct);
|
||||
}, [seek]);
|
||||
|
||||
const onScrubStart = useCallback((clientX: number) => {
|
||||
isDragging.current = true;
|
||||
seekFromX(clientX);
|
||||
}, [seekFromX]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: TouchEvent | MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
seekFromX(clientX);
|
||||
};
|
||||
const onEnd = () => { isDragging.current = false; };
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
window.addEventListener('touchmove', onMove, { passive: true });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
}, [seekFromX]);
|
||||
|
||||
// Drawers
|
||||
const [showQueue, setShowQueue] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// ── Empty state ──
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<div className="mp-view">
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
<div className="mp-empty">
|
||||
<Music size={56} style={{ opacity: 0.25 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bgStyle: CSSProperties = {
|
||||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mp-view" style={bgStyle}>
|
||||
{/* Header */}
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="mp-cover-wrap">
|
||||
{resolvedCover ? (
|
||||
<img src={resolvedCover} alt="" className="mp-cover" />
|
||||
) : (
|
||||
<div className="mp-cover mp-cover-fallback">
|
||||
<Music size={64} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Metadata */}
|
||||
<div className="mp-meta">
|
||||
<div className="mp-meta-text">
|
||||
<div className="mp-title truncate">{currentTrack.title}</div>
|
||||
<div
|
||||
className="mp-artist truncate"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</div>
|
||||
{(() => {
|
||||
const parts = [
|
||||
currentTrack.year,
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0
|
||||
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
className={`mp-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div className="mp-scrubber-wrap">
|
||||
<div
|
||||
className="mp-scrubber"
|
||||
ref={scrubberRef}
|
||||
onMouseDown={e => onScrubStart(e.clientX)}
|
||||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||||
>
|
||||
<div className="mp-scrubber-bg" />
|
||||
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
|
||||
</div>
|
||||
<div className="mp-scrubber-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="mp-controls">
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-sm"
|
||||
onClick={() => shuffleQueue()}
|
||||
aria-label={t('queue.shuffle')}
|
||||
>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`mp-ctrl-btn mp-ctrl-sm`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Utility Footer */}
|
||||
<div className="mp-footer">
|
||||
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
|
||||
<MicVocal size={20} />
|
||||
<span>{t('player.lyrics')}</span>
|
||||
</button>
|
||||
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
|
||||
<ListMusic size={20} />
|
||||
<span>{t('queue.title')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Drawer */}
|
||||
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
|
||||
|
||||
{/* Lyrics Drawer */}
|
||||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
|
||||
function loadRecent(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function saveRecent(q: string, prev: string[]): string[] {
|
||||
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
}
|
||||
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setLoading(false); return; }
|
||||
setLoading(true);
|
||||
try { setResults(await search(q)); }
|
||||
finally { setLoading(false); }
|
||||
}, 300),
|
||||
[musicLibraryFilterVersion]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
};
|
||||
|
||||
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
|
||||
const goCategory = (path: string) => { navigate(path); onClose(); };
|
||||
const playSong = (song: SearchResults['songs'][number]) => {
|
||||
commit(query);
|
||||
playTrack(songToTrack(song));
|
||||
onClose();
|
||||
};
|
||||
const useRecent = (term: string) => {
|
||||
setQuery(term);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
const removeRecent = (term: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setRecentSearches(prev => {
|
||||
const updated = prev.filter(s => s !== term);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
const showEmpty = !query;
|
||||
|
||||
return createPortal(
|
||||
<div className="mobile-search-overlay">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="mobile-search-bar">
|
||||
<div className="mobile-search-field">
|
||||
{loading ? (
|
||||
<div className="mobile-search-spinner" />
|
||||
) : (
|
||||
<Search size={16} className="mobile-search-icon" />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="mobile-search-input"
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="mobile-search-clear"
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mobile-search-cancel" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-search-results">
|
||||
{/* ── Empty state ── */}
|
||||
{showEmpty && (
|
||||
<div className="mobile-search-empty-state">
|
||||
{recentSearches.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
|
||||
{recentSearches.map(term => (
|
||||
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
|
||||
<div className="mobile-search-avatar">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div className="mobile-search-item-info" style={{ flex: 1 }}>
|
||||
<span className="mobile-search-item-title">{term}</span>
|
||||
</div>
|
||||
<button
|
||||
className="mobile-search-recent-remove"
|
||||
onClick={e => removeRecent(term, e)}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.browse')}</div>
|
||||
<div className="mobile-search-chips">
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
|
||||
<Music2 size={15} /> {t('search.albums')}
|
||||
</button>
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
|
||||
<Users size={15} /> {t('search.artists')}
|
||||
</button>
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
|
||||
<Music size={15} /> {t('search.genres')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-search-hint">
|
||||
<Search size={52} className="mobile-search-hint-icon" />
|
||||
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── No results ── */}
|
||||
{!loading && query && !hasResults && (
|
||||
<div className="mobile-search-noresults">
|
||||
{t('search.noResults', { query })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
{hasResults && (
|
||||
<>
|
||||
{results!.artists.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.artists')}</div>
|
||||
{results!.artists.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{t('search.artists')}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results!.albums.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.albums')}</div>
|
||||
{results!.albums.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||
{a.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Disc3 size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{a.artist}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results!.songs.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
||||
{results!.songs.map(s => (
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Music size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{s.title}</span>
|
||||
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -153,13 +154,66 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueHeaderProps {
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
showRemainingTime: boolean;
|
||||
setShowRemainingTime: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
t: TFunction;
|
||||
}
|
||||
function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
if (queue.length === 0) return null;
|
||||
const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0);
|
||||
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0));
|
||||
|
||||
const fmt = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
|
||||
<span
|
||||
onClick={() => setShowRemainingTime((v: boolean) => !v)}
|
||||
data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")}
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--accent)",
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur}
|
||||
</span>
|
||||
</div>
|
||||
{activePlaylist && (
|
||||
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<ListMusic size={10} style={{ flexShrink: 0 }} />
|
||||
<span className="truncate">{activePlaylist.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
@@ -275,6 +329,15 @@ export default function QueuePanel() {
|
||||
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [enqueueAt]);
|
||||
|
||||
useEffect(function queueAutoScroll() {
|
||||
if (!queueListRef.current || queueIndex < 0) return;
|
||||
if (activeTab !== 'queue') return;
|
||||
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
const nextSong = songs[queueIndex + 1];
|
||||
if (!nextSong) return;
|
||||
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
}, [currentTrack, activeTab]);
|
||||
|
||||
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -333,47 +396,17 @@ export default function QueuePanel() {
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const remainingSecs = Math.max(0,
|
||||
(queue[queueIndex]?.duration ?? 0) - currentTime
|
||||
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
|
||||
);
|
||||
const fmt = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
return (
|
||||
<span
|
||||
onClick={() => setShowRemainingTime(v => !v)}
|
||||
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
|
||||
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{activePlaylist && (
|
||||
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ListMusic size={10} style={{ flexShrink: 0 }} />
|
||||
<span className="truncate">{activePlaylist.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
showRemainingTime={showRemainingTime}
|
||||
setShowRemainingTime={setShowRemainingTime}
|
||||
activePlaylist={activePlaylist}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
|
||||
+147
-9
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -7,7 +8,8 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -22,10 +24,11 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
@@ -44,8 +47,70 @@ export default function Sidebar({
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
|
||||
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
|
||||
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
|
||||
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||
const selectedFolderName =
|
||||
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
|
||||
const libraryTriggerPlain = filterId === 'all';
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const el = libraryTriggerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setDropdownRect({
|
||||
top: r.bottom + 4,
|
||||
left: r.left,
|
||||
width: r.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!libraryDropdownOpen) return;
|
||||
updateDropdownPosition();
|
||||
const onWin = () => updateDropdownPosition();
|
||||
window.addEventListener('resize', onWin);
|
||||
window.addEventListener('scroll', onWin, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWin);
|
||||
window.removeEventListener('scroll', onWin, true);
|
||||
};
|
||||
}, [libraryDropdownOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryDropdownOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (libraryTriggerRef.current?.contains(t)) return;
|
||||
const panel = document.querySelector('.nav-library-dropdown-panel');
|
||||
if (panel?.contains(t)) return;
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setLibraryDropdownOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [libraryDropdownOpen]);
|
||||
|
||||
const pickLibrary = (id: 'all' | string) => {
|
||||
setMusicLibraryFilter(id);
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
@@ -73,8 +138,81 @@ export default function Sidebar({
|
||||
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
||||
</button>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Hauptnavigation">
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
|
||||
<nav className="sidebar-nav" aria-label="Main navigation">
|
||||
{!isCollapsed && (showLibraryPicker ? (
|
||||
<>
|
||||
<button
|
||||
ref={libraryTriggerRef}
|
||||
type="button"
|
||||
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
|
||||
onClick={() => {
|
||||
setLibraryDropdownOpen(o => !o);
|
||||
}}
|
||||
aria-label={t('sidebar.libraryScope')}
|
||||
aria-expanded={libraryDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-tooltip={libraryDropdownOpen ? undefined : t('sidebar.libraryScope')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{!libraryTriggerPlain ? (
|
||||
<Music2 size={16} className="nav-library-scope-icon" strokeWidth={2} aria-hidden />
|
||||
) : null}
|
||||
<div className="nav-library-scope-text">
|
||||
<span className="nav-library-scope-title">{t('sidebar.library')}</span>
|
||||
{selectedFolderName ? (
|
||||
<span className="nav-library-scope-subtitle" data-tooltip={selectedFolderName} data-tooltip-pos="right">
|
||||
{selectedFolderName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown size={16} strokeWidth={2.25} className="nav-library-scope-chevron" aria-hidden />
|
||||
</button>
|
||||
{libraryDropdownOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
|
||||
role="listbox"
|
||||
aria-label={t('sidebar.libraryScope')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdownRect.top,
|
||||
left: dropdownRect.left,
|
||||
width: dropdownRect.width,
|
||||
minWidth: dropdownRect.width,
|
||||
maxWidth: dropdownRect.width,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={filterId === 'all'}
|
||||
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
|
||||
onClick={() => pickLibrary('all')}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
|
||||
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
|
||||
</button>
|
||||
{musicFolders.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={filterId === f.id}
|
||||
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
|
||||
onClick={() => pickLibrary(f.id)}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{f.name}</span>
|
||||
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||
))}
|
||||
{visibleLibrary.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -117,7 +255,7 @@ export default function Sidebar({
|
||||
)}
|
||||
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystem.map(item => (
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
|
||||
@@ -42,9 +42,14 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
group: 'Open Source Classics',
|
||||
themes: [
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
|
||||
{ id: 'carbonfox', label: 'Carbonfox', bg: '#161616', card: '#1c1c1c', accent: '#be95ff' },
|
||||
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
|
||||
{ id: 'dracula', label: 'Dracula', bg: '#282a36', card: '#44475a', accent: '#bd93f9' },
|
||||
{ id: 'dawnfox', label: 'Dawnfox', bg: '#faf4ed', card: '#ebe0df', accent: '#907aa9' },
|
||||
{ id: 'dayfox', label: 'Dayfox', bg: '#f6f2ee', card: '#dbd1dd', accent: '#2848a9' },
|
||||
{ id: 'duskfox', label: 'Duskfox', bg: '#232136', card: '#2d2a45', accent: '#c4a7e7' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
@@ -53,8 +58,11 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'nightfox', label: 'Nightfox', bg: '#192330', card: '#212e3f', accent: '#719cd6' },
|
||||
{ id: 'nordfox', label: 'Nordfox', bg: '#2e3440', card: '#39404f', accent: '#81a1c1' },
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
|
||||
{ id: 'terafox', label: 'Terafox', bg: '#152528', card: '#1d3337', accent: '#a1cdd8' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,13 +35,20 @@ export default function TooltipPortal() {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (!target) setTooltip(null);
|
||||
};
|
||||
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (t) setTooltip(null);
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
document.addEventListener('mousemove', onMove, { passive: true });
|
||||
document.addEventListener('mousedown', onDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mousedown', onDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 800;
|
||||
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
let mql: MediaQueryList | null = null;
|
||||
|
||||
function getMql(): MediaQueryList {
|
||||
if (!mql) mql = window.matchMedia(query);
|
||||
return mql;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const m = getMql();
|
||||
m.addEventListener('change', cb);
|
||||
return () => m.removeEventListener('change', cb);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return getMql().matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the viewport width is below 800px.
|
||||
* Updates in real-time on resize via `matchMedia`.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
source: LyricsSource | null;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// Session-level cache — survives tab switches and component unmount/remount.
|
||||
export const lyricsCache = new Map<string, CachedLyrics>();
|
||||
|
||||
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
||||
export function parseStructuredLyrics(
|
||||
lyrics: SubsonicStructuredLyrics,
|
||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
||||
if (lyrics.issynced && lyrics.line.length > 0) {
|
||||
const lines: LrcLine[] = lyrics.line
|
||||
.filter(l => l.start !== undefined)
|
||||
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
if (lines.length > 0) return { syncedLines: lines, plainLyrics: null };
|
||||
}
|
||||
const plain = lyrics.line.map(l => l.value).join('\n').trim();
|
||||
return { syncedLines: null, plainLyrics: plain || null };
|
||||
}
|
||||
|
||||
export interface UseLyricsResult {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
source: LyricsSource | null;
|
||||
loading: boolean;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
|
||||
const [source, setSource] = useState<LyricsSource | null>(cached?.source ?? null);
|
||||
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
const hit = lyricsCache.get(currentTrack.id);
|
||||
if (hit) {
|
||||
setSyncedLines(hit.syncedLines);
|
||||
setPlainLyrics(hit.plainLyrics);
|
||||
setSource(hit.source);
|
||||
setNotFound(hit.notFound);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSyncedLines(null);
|
||||
setPlainLyrics(null);
|
||||
setSource(null);
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
|
||||
const store = (entry: CachedLyrics) => {
|
||||
if (cancelled) return;
|
||||
lyricsCache.set(currentTrack.id, entry);
|
||||
setSyncedLines(entry.syncedLines);
|
||||
setPlainLyrics(entry.plainLyrics);
|
||||
setSource(entry.source);
|
||||
setNotFound(entry.notFound);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchServer = async (): Promise<boolean> => {
|
||||
const structured = await getLyricsBySongId(currentTrack.id);
|
||||
if (!structured) return false;
|
||||
const parsed = parseStructuredLyrics(structured);
|
||||
if (!parsed.syncedLines && !parsed.plainLyrics) return false;
|
||||
store({ ...parsed, source: 'server', notFound: false });
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchLrclibFn = async (): Promise<boolean> => {
|
||||
try {
|
||||
const result = await fetchLyrics(
|
||||
currentTrack.artist ?? '',
|
||||
currentTrack.title,
|
||||
currentTrack.album ?? '',
|
||||
currentTrack.duration ?? 0,
|
||||
);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false;
|
||||
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
||||
const synced = lines && lines.length > 0 ? lines : null;
|
||||
store({ syncedLines: synced, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const [first, second] = lyricsServerFirst
|
||||
? [fetchServer, fetchLrclibFn]
|
||||
: [fetchLrclibFn, fetchServer];
|
||||
|
||||
if (cancelled) return;
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) return;
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from './api/subsonic';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useHotCacheStore } from './store/hotCacheStore';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
|
||||
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingQueue: PrefetchJob[] = [];
|
||||
let workerRunning = false;
|
||||
|
||||
function debounceMs(): number {
|
||||
const s = useAuthStore.getState().hotCacheDebounceSec;
|
||||
if (!Number.isFinite(s) || s < 0) return 0;
|
||||
return Math.min(600, s) * 1000;
|
||||
}
|
||||
|
||||
function enqueueJobs(jobs: PrefetchJob[]) {
|
||||
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
||||
for (const j of jobs) {
|
||||
const k = `${j.serverId}:${j.trackId}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
pendingQueue.push(j);
|
||||
}
|
||||
void runWorker();
|
||||
}
|
||||
|
||||
async function runWorker() {
|
||||
if (workerRunning) return;
|
||||
workerRunning = true;
|
||||
try {
|
||||
while (pendingQueue.length > 0) {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
||||
pendingQueue.length = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
while (getDeferHotCachePrefetch()) {
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
const job = pendingQueue.shift();
|
||||
if (!job) break;
|
||||
|
||||
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
||||
if (maxBytes <= 0) continue;
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
|
||||
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const wantIds = new Set(
|
||||
queue
|
||||
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
||||
.map(t => t.id),
|
||||
);
|
||||
if (!wantIds.has(job.trackId)) continue;
|
||||
|
||||
const url = buildStreamUrl(job.trackId);
|
||||
try {
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', {
|
||||
trackId: job.trackId,
|
||||
serverId: job.serverId,
|
||||
url,
|
||||
suffix: job.suffix,
|
||||
customDir,
|
||||
});
|
||||
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
|
||||
const fresh = usePlayerStore.getState();
|
||||
await useHotCacheStore.getState().evictToFit(
|
||||
fresh.queue,
|
||||
fresh.queueIndex,
|
||||
maxBytes,
|
||||
auth.activeServerId,
|
||||
customDir,
|
||||
);
|
||||
} catch {
|
||||
/* network / HTTP — skip */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
workerRunning = false;
|
||||
if (pendingQueue.length > 0) void runWorker();
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(serverId: string, trackId: string): string {
|
||||
return `${serverId}:${trackId}`;
|
||||
}
|
||||
|
||||
function scheduleReplan() {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
const ms = debounceMs();
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
void replanNow();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
async function replanNow() {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) return;
|
||||
|
||||
const serverId = auth.activeServerId;
|
||||
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
if (maxBytes <= 0) return;
|
||||
|
||||
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
|
||||
if (currentRadio) return;
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
const hot = useHotCacheStore.getState();
|
||||
|
||||
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
||||
|
||||
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
||||
const jobs: PrefetchJob[] = [];
|
||||
for (const t of targets) {
|
||||
if (offline.isDownloaded(t.id, serverId)) continue;
|
||||
if (hot.entries[entryKey(serverId, t.id)]) continue;
|
||||
jobs.push({
|
||||
trackId: t.id,
|
||||
serverId,
|
||||
suffix: t.suffix || 'mp3',
|
||||
});
|
||||
}
|
||||
enqueueJobs(jobs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to queue/auth changes and run debounced prefetch.
|
||||
* Call once from the app shell.
|
||||
*/
|
||||
export function initHotCachePrefetch(): () => void {
|
||||
let lastQueueRef: unknown = null;
|
||||
let lastQueueIndex = -1;
|
||||
const unsubPlayer = usePlayerStore.subscribe(state => {
|
||||
const q = state.queue;
|
||||
const i = state.queueIndex;
|
||||
if (q === lastQueueRef && i === lastQueueIndex) return;
|
||||
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
|
||||
lastQueueRef = q;
|
||||
lastQueueIndex = i;
|
||||
if (onlyIndexMoved) void replanNow();
|
||||
else scheduleReplan();
|
||||
});
|
||||
|
||||
let lastAuthSig = '';
|
||||
const unsubAuth = useAuthStore.subscribe(state => {
|
||||
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
|
||||
if (sig === lastAuthSig) return;
|
||||
lastAuthSig = sig;
|
||||
if (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan();
|
||||
});
|
||||
|
||||
void replanNow();
|
||||
|
||||
return () => {
|
||||
unsubPlayer();
|
||||
unsubAuth();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
pendingQueue.length = 0;
|
||||
};
|
||||
}
|
||||
+12
-4734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
export const deTranslation = {
|
||||
sidebar: {
|
||||
library: 'Bibliothek',
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'Neueste',
|
||||
allAlbums: 'Alle Alben',
|
||||
randomAlbums: 'Zufallsalben',
|
||||
artists: 'Künstler',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
help: 'Hilfe',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Persönliche Favoriten',
|
||||
recent: 'Zuletzt hinzugefügt',
|
||||
mostPlayed: 'Meistgehört',
|
||||
recentlyPlayed: 'Kürzlich gespielt',
|
||||
discover: 'Entdecken',
|
||||
loadMore: 'Mehr laden',
|
||||
discoverMore: 'Mehr entdecken',
|
||||
discoverArtists: 'Künstler entdecken',
|
||||
discoverArtistsMore: 'Alle Künstler'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Album des Augenblicks',
|
||||
playAlbum: 'Album abspielen',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Suchen nach Künstler, Album oder Song…',
|
||||
noResults: 'Keine Ergebnisse für „{{query}}"',
|
||||
artists: 'Künstler',
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Suche leeren',
|
||||
title: 'Suche',
|
||||
resultsFor: 'Ergebnisse für „{{query}}"',
|
||||
album: 'Album',
|
||||
advanced: 'Erweiterte Suche',
|
||||
advancedSearchTerm: 'Suchbegriff',
|
||||
advancedSearchPlaceholder: 'Titel, Album, Künstler…',
|
||||
advancedGenre: 'Genre',
|
||||
advancedAllGenres: 'Alle Genres',
|
||||
advancedYear: 'Jahr',
|
||||
advancedYearFrom: 'von',
|
||||
advancedYearTo: 'bis',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Suchen',
|
||||
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
|
||||
advancedNoResults: 'Keine Ergebnisse gefunden.',
|
||||
advancedGenreNote: 'Songs werden zufällig aus dem Genre gewählt.',
|
||||
recentSearches: 'Zuletzt gesucht',
|
||||
browse: 'Stöbern',
|
||||
emptyHint: 'Was möchtest du hören?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wer hört was?',
|
||||
title: 'Wer hört was?',
|
||||
loading: 'Lädt…',
|
||||
nobody: 'Gerade hört niemand Musik.',
|
||||
minutesAgo: 'vor {{n}}m',
|
||||
nothingPlaying: 'Noch nichts am Laufen. Leg einen Track auf!',
|
||||
aboutArtist: 'Über den Künstler',
|
||||
fromAlbum: 'Aus diesem Album',
|
||||
viewAlbum: 'Album öffnen',
|
||||
goToArtist: 'Zum Künstler',
|
||||
readMore: 'Mehr lesen',
|
||||
showLess: 'Weniger anzeigen',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Track-Info',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Direkt abspielen',
|
||||
playNext: 'Als Nächstes abspielen',
|
||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||
enqueueAlbum: 'Ganzes Album einreihen',
|
||||
startRadio: 'Radio starten',
|
||||
lfmLove: 'Auf Last.fm liken',
|
||||
lfmUnlove: 'Last.fm-Like entfernen',
|
||||
favorite: 'Favorisieren',
|
||||
favoriteArtist: 'Künstler favorisieren',
|
||||
favoriteAlbum: 'Album favorisieren',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
|
||||
unfavoriteAlbum: 'Album aus Favoriten entfernen',
|
||||
removeFromQueue: 'Diesen Song entfernen',
|
||||
openAlbum: 'Album öffnen',
|
||||
goToArtist: 'Zum Künstler',
|
||||
download: 'Herunterladen (ZIP)',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
songInfo: 'Song-Infos',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
playAll: 'Alle abspielen',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||
artistBio: 'Künstler-Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Lade…',
|
||||
cacheOffline: 'Offline verfügbar machen',
|
||||
offlineCached: 'Offline verfügbar',
|
||||
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
||||
removeOffline: 'Offline-Cache löschen',
|
||||
offlineStorageFull: 'Offline-Speicher voll (Limit: {{mb}} MB). Lösche ein Album aus der Offline-Bibliothek oder erhöhe das Limit in den Einstellungen.',
|
||||
offlineStorageGoToSettings: 'Einstellungen',
|
||||
offlineStorageGoToLibrary: 'Offline-Bibliothek',
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
favorite: 'Als Favorit',
|
||||
noBio: 'Keine Biografie verfügbar.',
|
||||
moreByArtist: 'Mehr von {{artist}}',
|
||||
tracksCount: '{{n}} Tracks',
|
||||
goToArtist: 'Zu {{artist}} wechseln',
|
||||
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Interpret',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorit',
|
||||
trackRating: 'Bewertung',
|
||||
trackDuration: 'Dauer',
|
||||
trackTotal: 'Gesamt',
|
||||
columns: 'Spalten',
|
||||
notFound: 'Album nicht gefunden.',
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
albums: 'Alben',
|
||||
album: 'Album',
|
||||
playAll: 'Alle abspielen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
radio: 'Radio',
|
||||
loading: 'Lädt…',
|
||||
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
|
||||
notFound: 'Künstler nicht gefunden.',
|
||||
albumsBy: 'Alben von {{name}}',
|
||||
topTracks: 'Beliebteste Titel',
|
||||
noAlbums: 'Keine Alben gefunden.',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackDuration: 'Dauer',
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
favorite: 'Als Favorit',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
similarArtists: 'Ähnliche Künstler',
|
||||
cacheOffline: 'Diskografie offline speichern',
|
||||
offlineCached: 'Diskografie gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
uploadImage: 'Künstlerbild hochladen',
|
||||
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
empty: 'Du hast noch keine Favoriten gespeichert.',
|
||||
artists: 'Künstler',
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
playAll: 'Alle abspielen',
|
||||
removeSong: 'Aus Favoriten entfernen',
|
||||
stations: 'Radiosender',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
refresh: 'Neu laden',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
genreCount: 'Genres',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
loading: 'Genres werden geladen…',
|
||||
empty: 'Keine Genres gefunden.',
|
||||
albumsLoading: 'Alben werden geladen…',
|
||||
albumsEmpty: 'Keine Alben in diesem Genre gefunden.',
|
||||
loadMore: 'Mehr laden',
|
||||
back: 'Zurück',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Zufallsmix',
|
||||
remix: 'Neu mixen',
|
||||
remixTooltip: 'Neue Songs laden',
|
||||
playAll: 'Alle abspielen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Künstler',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorit',
|
||||
trackDuration: 'Dauer',
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
play: 'Abspielen',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
||||
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel, Album und Künstler — z. B. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword gesperrt',
|
||||
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
|
||||
genreAlreadyBlocked: 'Bereits gesperrt',
|
||||
artistBlocked: 'Künstler gesperrt',
|
||||
artistAddedToBlacklist: 'Künstler zur Filterliste hinzugefügt',
|
||||
artistClickHint: 'Klicken um diesen Künstler zu sperren',
|
||||
blacklistToggle: 'Keyword-Filter',
|
||||
genreMixTitle: 'Genre-Mix',
|
||||
genreMixDesc: 'Top 20 Genres nach Songanzahl — klicken für einen Zufallsmix',
|
||||
genreMixLoadMore: '10 weitere laden',
|
||||
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||
shuffleGenres: 'Andere Genres anzeigen',
|
||||
filterPanelTitle: 'Filter',
|
||||
filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.',
|
||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle Alben',
|
||||
sortByName: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (Künstler)',
|
||||
sortNewest: 'Neueste zuerst',
|
||||
sortRandom: 'Zufällig',
|
||||
yearFrom: 'Von',
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
},
|
||||
artists: {
|
||||
title: 'Künstler',
|
||||
search: 'Suchen…',
|
||||
all: 'Alle',
|
||||
gridView: 'Gitteransicht',
|
||||
listView: 'Listenansicht',
|
||||
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
|
||||
imagesOff: 'Künstlerbilder deaktiviert — zeigt nur Initialen',
|
||||
loadMore: 'Mehr laden',
|
||||
notFound: 'Keine Künstler gefunden.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Dein Navidrome Desktop Player',
|
||||
serverName: 'Server-Name (optional)',
|
||||
serverNamePlaceholder: 'Mein Navidrome',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
|
||||
username: 'Benutzername',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Passwort',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verstecken',
|
||||
connect: 'Verbinden',
|
||||
connecting: 'Verbinde…',
|
||||
connected: 'Verbunden!',
|
||||
error: 'Verbindung fehlgeschlagen – bitte Daten prüfen.',
|
||||
urlRequired: 'Bitte Server-URL eingeben.',
|
||||
savedServers: 'Gespeicherte Server',
|
||||
addNew: 'Oder neuen Server hinzufügen',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Verbunden',
|
||||
connectedTo: 'Verbunden mit {{server}}',
|
||||
disconnected: 'Getrennt',
|
||||
disconnectedFrom: '{{server}} nicht erreichbar — klicken für Einstellungen',
|
||||
checking: 'Verbinde…',
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'Keine Serververbindung',
|
||||
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
|
||||
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
|
||||
offlineLibraryTitle: 'Offline-Bibliothek',
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
offlineFilterAll: 'Alle',
|
||||
offlineFilterAlbums: 'Alben',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
},
|
||||
common: {
|
||||
albums: 'Alben',
|
||||
album: 'Album',
|
||||
loading: 'Lade…',
|
||||
loadingMore: 'Lade…',
|
||||
loadingPlaylists: 'Lade Playlists…',
|
||||
noAlbums: 'Keine Alben gefunden.',
|
||||
downloading: 'Lade…',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Zurück',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
delete: 'Löschen',
|
||||
use: 'Verwenden',
|
||||
add: 'Hinzufügen',
|
||||
active: 'Aktiv',
|
||||
download: 'Herunterladen',
|
||||
chooseDownloadFolder: 'Download-Ordner wählen',
|
||||
noFolderSelected: 'Kein Ordner ausgewählt',
|
||||
rememberDownloadFolder: 'Ordner merken',
|
||||
filterGenre: 'Genre-Filter',
|
||||
filterSearchGenres: 'Genres suchen…',
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} verfügbar',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
language: 'Sprache',
|
||||
languageEn: 'Englisch',
|
||||
languageDe: 'Deutsch',
|
||||
languageFr: 'Französisch',
|
||||
languageNl: 'Niederländisch',
|
||||
languageZh: 'Chinesisch',
|
||||
languageNb: 'Norwegisch',
|
||||
languageRu: 'Russisch',
|
||||
font: 'Schriftart',
|
||||
theme: 'Design',
|
||||
appearance: 'Darstellung',
|
||||
servers: 'Server',
|
||||
serverName: 'Server-Name',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUsername: 'Benutzername',
|
||||
serverPassword: 'Passwort',
|
||||
addServer: 'Server hinzufügen',
|
||||
addServerTitle: 'Neuen Server hinzufügen',
|
||||
useServer: 'Verwenden',
|
||||
deleteServer: 'Löschen',
|
||||
noServers: 'Keine Server gespeichert.',
|
||||
serverActive: 'Aktiv',
|
||||
confirmDeleteServer: 'Server „{{name}}" löschen?',
|
||||
serverConnecting: 'Verbinde…',
|
||||
serverConnected: 'Verbunden!',
|
||||
serverFailed: 'Verbindung fehlgeschlagen.',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
eqEnabled: 'Equalizer aktivieren',
|
||||
eqPreset: 'Preset',
|
||||
eqPresetCustom: 'Benutzerdefiniert',
|
||||
eqPresetBuiltin: 'Eingebaute Presets',
|
||||
eqPresetCustomGroup: 'Meine Presets',
|
||||
eqSavePreset: 'Als Preset speichern',
|
||||
eqPresetName: 'Preset-Name…',
|
||||
eqDeletePreset: 'Preset löschen',
|
||||
eqResetBands: 'Auf Flat zurücksetzen',
|
||||
eqPreGain: 'Vorverstärkung',
|
||||
eqResetPreGain: 'Vorverstärkung zurücksetzen',
|
||||
eqAutoEqTitle: 'AutoEQ Kopfhörer-Suche',
|
||||
eqAutoEqPlaceholder: 'Kopfhörer / IEM Modell suchen…',
|
||||
eqAutoEqSearching: 'Suche…',
|
||||
eqAutoEqNoResults: 'Keine Ergebnisse gefunden',
|
||||
eqAutoEqError: 'Suche fehlgeschlagen',
|
||||
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
|
||||
eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Mit Last.fm verbinden',
|
||||
lfmConnecting: 'Warte auf Bestätigung…',
|
||||
lfmConfirm: 'Ich habe die App autorisiert',
|
||||
lfmConnected: 'Verbunden als',
|
||||
lfmDisconnect: 'Trennen',
|
||||
lfmConnectDesc: 'Verbinde deinen Last.fm Account, um Scrobbling und Now-Playing-Updates direkt aus Psysonic heraus zu aktivieren — keine Navidrome-Konfiguration erforderlich.',
|
||||
lfmOpenBrowser: 'Es öffnet sich ein Browserfenster. Autorisiere Psysonic auf Last.fm und klicke dann unten auf den Button.',
|
||||
lfmScrobbles: '{{n}} Scrobbles',
|
||||
lfmMemberSince: 'Dabei seit {{year}}',
|
||||
scrobbleEnabled: 'Scrobbling aktiviert',
|
||||
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
||||
behavior: 'App-Verhalten',
|
||||
cacheTitle: 'Max. Speichergröße',
|
||||
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
||||
cacheUsedImages: 'Bilder:',
|
||||
cacheUsedOffline: 'Offline-Tracks:',
|
||||
cacheUsedHot: 'Belegter Speicher:',
|
||||
hotCacheTrackCount: 'Titel im Cache:',
|
||||
cacheMaxLabel: 'Max. Größe',
|
||||
cacheClearBtn: 'Cache leeren',
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
offlineDirTitle: 'Offline-Bibliothek (In-App)',
|
||||
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
|
||||
offlineDirDefault: 'Standard (App-Daten)',
|
||||
offlineDirChange: 'Verzeichnis ändern',
|
||||
offlineDirClear: 'Auf Standard zurücksetzen',
|
||||
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
|
||||
hotCacheTitle: 'Hot-Playback-Cache',
|
||||
hotCacheAlphaBadge: 'Alpha',
|
||||
hotCacheDisclaimer: 'Lädt kommende Warteschlangen-Titel vor und behält zuletzt gespielte. Bei aktivierter Option: Speicherplatz und Netzwerk.',
|
||||
hotCacheDirDefault: 'Standard (App-Daten)',
|
||||
hotCacheDirChange: 'Ordner ändern',
|
||||
hotCacheDirClear: 'Auf Standard zurücksetzen',
|
||||
hotCacheDirHint: 'Beim Ordnerwechsel wird der App-Index zurückgesetzt; alte Dateien bleiben am alten Ort, bis Sie sie löschen.',
|
||||
hotCacheEnabled: 'Hot-Playback-Cache aktivieren',
|
||||
hotCacheMaxMb: 'Maximale Cache-Größe',
|
||||
hotCacheDebounce: 'Debounce bei Warteschlangen-Änderungen',
|
||||
hotCacheDebounceImmediate: 'Sofort',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Hot-Cache leeren',
|
||||
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
||||
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
showTrayIcon: 'Tray-Icon anzeigen',
|
||||
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
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.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
clearFolder: 'Download-Ordner löschen',
|
||||
logout: 'Abmelden',
|
||||
aboutTitle: 'Über Psysonic',
|
||||
aboutDesc: 'Ein moderner Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Basiert auf Tauri v2 mit einer nativen Rust-Audio-Engine — schlank und schnell, aber vollgepackt mit Features: Wellenform-Seekbar, synchronisierte Lyrics, Last.fm-Integration, 10-Band-EQ, Crossfade, nahtlose Wiedergabe, Replay Gain, Genre-Browser und eine große Theme-Bibliothek.',
|
||||
aboutFeatures: 'Multi-Server · Last.fm Scrobbling & Love · Vollbild Ambient Stage · Wellenform-Seek · Sync-Lyrics · 10-Band-EQ · Crossfade & Gapless · Replay Gain · Genres · 63 Themes · 5 Sprachen',
|
||||
aboutLicense: 'Lizenz',
|
||||
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Mitwirkende',
|
||||
aboutSpecialThanksLabel: 'Besonderer Dank',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
|
||||
randomMixTitle: 'Zufallsmix',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Speicher & Downloads',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
tabInput: 'Eingabe',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
shortcutListening: 'Taste drücken…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Löschen',
|
||||
globalShortcutsTitle: 'Globale Shortcuts',
|
||||
globalShortcutsNote: 'Funktionieren systemweit, auch wenn Psysonic im Hintergrund läuft. Mindestens Ctrl, Alt oder Super als Modifier erforderlich.',
|
||||
shortcutPlayPause: 'Wiedergabe / Pause',
|
||||
shortcutNext: 'Nächster Titel',
|
||||
shortcutPrev: 'Vorheriger Titel',
|
||||
shortcutVolumeUp: 'Lautstärke erhöhen',
|
||||
shortcutVolumeDown: 'Lautstärke verringern',
|
||||
shortcutSeekForward: '10s vorspulen',
|
||||
shortcutSeekBackward: '10s zurückspulen',
|
||||
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
backupImport: 'Einstellungen importieren',
|
||||
backupImportDesc: 'Stellt Einstellungen aus einer .psybkp-Datei wieder her. Die App wird nach dem Import neu geladen.',
|
||||
backupImportConfirm: 'Alle aktuellen Einstellungen werden überschrieben. Fortfahren?',
|
||||
backupSuccess: 'Backup gespeichert',
|
||||
backupImportSuccess: 'Einstellungen wiederhergestellt — wird neu geladen…',
|
||||
backupImportError: 'Ungültige oder beschädigte Backup-Datei.',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
||||
preloadCustom: 'Benutzerdefiniert',
|
||||
preloadCustomSeconds: 'Sekunden vor Ende: {{n}}',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Was ist neu',
|
||||
dontShowAgain: 'Nicht mehr anzeigen',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
s1: 'Erste Schritte',
|
||||
q1: 'Welche Server sind kompatibel?',
|
||||
a1: 'Psysonic funktioniert mit jedem Subsonic-kompatiblen Server: Navidrome, Gonic, Subsonic, Airsonic und anderen. Navidrome ist die empfohlene Wahl.',
|
||||
q2: 'Wie verbinde ich mich mit meinem Server?',
|
||||
a2: 'Öffne die Einstellungen und klicke auf "Server hinzufügen". Gib die Server-URL (z. B. 192.168.1.100:4533), Benutzername und Passwort ein. Psysonic testet die Verbindung vor dem Speichern — bei Fehler wird nichts gespeichert.',
|
||||
q3: 'Kann ich mehrere Server verwenden?',
|
||||
a3: 'Ja. Du kannst beliebig viele Server in den Einstellungen hinzufügen und jederzeit zwischen ihnen wechseln. Immer ist nur ein Server aktiv.',
|
||||
s2: 'Wiedergabe',
|
||||
q4: 'Wie spiele ich Musik ab?',
|
||||
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
|
||||
q5: 'Welche Tastenkürzel gibt es?',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren auf allen Plattformen. Unter Linux bitte die Buttons in der Playerleiste nutzen. In-App-Kürzel und systemweite globale Shortcuts können in Einstellungen → Tastenkürzel / Globale Shortcuts angepasst werden.',
|
||||
q6: 'Was ist die Warteschlange?',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
|
||||
q7: 'Wie öffne ich den Vollbild-Player?',
|
||||
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
|
||||
q8: 'Wie funktioniert die Wiederholfunktion?',
|
||||
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
|
||||
s3: 'Bibliothek',
|
||||
q9: 'Wie lade ich ein Album herunter?',
|
||||
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server packt das Album zunächst in eine ZIP-Datei — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann es einen Moment dauern, bevor der Download tatsächlich startet. Das ist normal: Der Fortschrittsbalken erscheint erst, wenn der Server fertig ist und die Übertragung beginnt.',
|
||||
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
|
||||
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
|
||||
q11: 'Was ist das Karussell auf der Startseite?',
|
||||
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
|
||||
s4: 'Einstellungen',
|
||||
q12: 'Wie ändere ich das Theme?',
|
||||
a12: 'Einstellungen → Design. Eine große Auswahl an Themes in 8 Gruppen: Psysonic Themes, Mediaplayer, Betriebssysteme, Spiele, Filme, Serien, Social Media und Open-Source-Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
|
||||
q13: 'Wie ändere ich die Sprache?',
|
||||
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch, Niederländisch und Chinesisch werden unterstützt.',
|
||||
q15: 'Wie lege ich einen Download-Ordner fest?',
|
||||
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'Wie funktioniert Scrobbling?',
|
||||
a16: 'Psysonic scrobbled direkt zu Last.fm — keine Navidrome-Konfiguration nötig. Last.fm-Konto in Einstellungen → Last.fm verbinden und Scrobbling dort aktivieren.',
|
||||
q17: 'Wann wird ein Scrobble gesendet?',
|
||||
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
|
||||
q22: 'Was ist die Waveform in der Playerleiste?',
|
||||
a22: 'Die Waveform-Leiste ersetzt den klassischen Fortschrittsbalken. Klick auf eine beliebige Stelle zum Springen, ziehen zum Scrubben. Der gespielte Teil leuchtet in einem Blau-Mauve-Farbverlauf, der gepufferte Bereich erscheint etwas heller, der ungespielter Teil ist abgeblendet.',
|
||||
q24: 'Kann ich die Warteschlange mischen?',
|
||||
a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.',
|
||||
q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?',
|
||||
a25: 'Ja — sie öffnen sich im Standard-Systembrowser. Der Button zeigt kurz eine Bestätigungsmeldung beim Klick.',
|
||||
s7: 'Zufallsmix',
|
||||
q26: 'Was ist der Zufallsmix?',
|
||||
a26: 'Der Zufallsmix erstellt eine Playlist aus zufälligen Tracks deiner gesamten Bibliothek. Über "Zufallsmix" in der Seitenleiste erreichbar, dann "Neuer Mix" klicken. Die Anzahl der Tracks ist vor der Generierung einstellbar.',
|
||||
q27: 'Was ist der Keyword-Filter?',
|
||||
a27: 'Der Keyword-Filter schließt Tracks aus, deren Genre, Titel oder Album bestimmte Wörter enthält. Hörbücher werden automatisch gefiltert. Eigene Keywords in Einstellungen → Zufallsmix hinzufügen oder per Klick auf ein Genre-Tag direkt in der Trackliste.',
|
||||
q28: 'Was ist der Super-Genre-Mix?',
|
||||
a28: 'Der Super-Genre-Mix fasst die Bibliothek in übergeordnete Kategorien zusammen (Rock, Metal, Electronic, Jazz, Klassik usw.) und erstellt daraus einen fokussierten Mix. Einen Kategorie-Chip unterhalb der Trackliste auswählen. Ergebnisse erscheinen schrittweise, während die einzelnen Sub-Genres geladen werden.',
|
||||
s6: 'Problemlösung',
|
||||
q18: 'Cover und Künstlerbilder laden langsam.',
|
||||
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
|
||||
q19: 'Der Verbindungstest schlägt fehl.',
|
||||
a19: 'URL inklusive Port prüfen (z. B. http://192.168.1.100:4533). Sicherstellen, dass keine Firewall die Verbindung blockiert. Im lokalen Netzwerk http:// statt https:// versuchen. Benutzername und Passwort kontrollieren.',
|
||||
q20: 'Kein Ton unter Linux.',
|
||||
a20: 'Psysonic ist als .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) und über AUR für Arch/CachyOS verfügbar (yay -S psysonic). Es gibt kein AppImage. Falls kein Ton kommt, sicherstellen, dass PipeWire oder PulseAudio läuft.',
|
||||
q21: 'Die App zeigt einen schwarzen Bildschirm unter Linux.',
|
||||
a21: 'Meist ein GPU/EGL-Treiberproblem in WebKitGTK. Mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten. Das AUR-Paket und die offiziellen .deb/.rpm-Pakete setzen diese Variablen automatisch.',
|
||||
q29: 'Was sind Crossfade und nahtlose Wiedergabe?',
|
||||
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (1–10 s) einstellbar.',
|
||||
q30: 'Zeigt Psysonic Songtexte an?',
|
||||
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
|
||||
q31: 'Kann ich Tastenkürzel anpassen?',
|
||||
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
|
||||
q32: 'Kann ich die Schriftart ändern?',
|
||||
a32: 'Ja. Einstellungen → Schriftart bietet 10 Schriften zur Auswahl, darunter IBM Plex Mono, Fira Code, JetBrains Mono und Courier Prime. Die gewählte Schrift gilt für die gesamte Oberfläche.',
|
||||
s8: 'Offline-Modus',
|
||||
q34: 'Was ist der Offline-Modus?',
|
||||
a34: 'Der Offline-Modus (Beta) ermöglicht das Zwischenspeichern von Alben auf dem Gerät, sodass die Wiedergabe ohne aktive Serververbindung möglich ist. Gecachte Tracks werden lokal gespeichert und direkt von der Festplatte abgespielt — ohne Netzwerkzugriff.',
|
||||
q35: 'Wie speichere ich ein Album für die Offline-Nutzung?',
|
||||
a35: 'Album öffnen und auf das Download-Symbol im Album-Header klicken. Psysonic lädt alle Tracks im Hintergrund herunter. Der Fortschritt wird am Button angezeigt. Nach dem Download wird das Symbol grün. Gecachte Alben können in der Offline-Bibliothek (Seitenleiste) verwaltet und gelöscht werden.',
|
||||
q36: 'Wie viel Speicherplatz darf der Cache belegen?',
|
||||
a36: 'In Einstellungen → Bibliothek kann eine maximale Cache-Größe festgelegt werden. Wird das Limit erreicht, erscheint ein Warnbanner auf der Albumseite. Einzelne Alben können in der Offline-Bibliothek gelöscht werden, um Speicherplatz freizugeben.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
savePlaylist: 'Playlist speichern',
|
||||
updatePlaylist: 'Playlist aktualisieren',
|
||||
filterPlaylists: 'Playlists filtern…',
|
||||
playlistName: 'Name der Playlist',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
loadPlaylist: 'Playlist laden',
|
||||
loading: 'Lade…',
|
||||
noPlaylists: 'Keine Playlists gefunden.',
|
||||
load: 'Warteschlange ersetzen & abspielen',
|
||||
appendToQueue: 'An Warteschlange anhängen',
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||
clear: 'Leeren',
|
||||
shuffle: 'Warteschlange mischen',
|
||||
gapless: 'Nahtlos',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
showRemaining: 'Restzeit anzeigen',
|
||||
showTotal: 'Gesamtzeit anzeigen',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
recentlyPlayed: 'Zuletzt gehört',
|
||||
mostPlayed: 'Meistgespielte Alben',
|
||||
highestRated: 'Höchstbewertete Alben',
|
||||
genreDistribution: 'Genre-Verteilung (Top 20)',
|
||||
loadMore: 'Mehr laden',
|
||||
statArtists: 'Künstler',
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Gesamtspielzeit',
|
||||
genreInsights: 'Genre-Einblicke',
|
||||
formatDistribution: 'Format-Verteilung',
|
||||
formatSample: 'Stichprobe von {{n}} Titeln',
|
||||
computing: 'Wird berechnet…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Alben',
|
||||
recentlyAdded: 'Neu hinzugefügt',
|
||||
decadeDistribution: 'Alben nach Jahrzehnt',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Alben',
|
||||
decadeUnknown: 'Unbekannt',
|
||||
lfmTitle: 'Last.fm Statistiken',
|
||||
lfmTopArtists: 'Top-Künstler',
|
||||
lfmTopAlbums: 'Top-Alben',
|
||||
lfmTopTracks: 'Top-Titel',
|
||||
lfmPlays: '{{count}} Plays',
|
||||
lfmPeriodOverall: 'Gesamt',
|
||||
lfmPeriod7day: '7 Tage',
|
||||
lfmPeriod1month: '1 Monat',
|
||||
lfmPeriod3month: '3 Monate',
|
||||
lfmPeriod6month: '6 Monate',
|
||||
lfmPeriod12month: '12 Monate',
|
||||
lfmNotConnected: 'Verbinde Last.fm in den Einstellungen, um deine Statistiken zu sehen.',
|
||||
lfmRecentTracks: 'Zuletzt gescrobbelt',
|
||||
lfmNowPlaying: 'Läuft gerade',
|
||||
lfmJustNow: 'gerade eben',
|
||||
lfmMinutesAgo: 'vor {{n}} Min.',
|
||||
lfmHoursAgo: 'vor {{n}} Std.',
|
||||
lfmDaysAgo: 'vor {{n}} Tagen',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
openFullscreen: 'Vollbild-Player öffnen',
|
||||
fullscreen: 'Vollbild-Player',
|
||||
closeFullscreen: 'Vollbild schließen',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
noTitle: 'Kein Titel',
|
||||
stop: 'Stop',
|
||||
prev: 'Vorheriger Titel',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
next: 'Nächster Titel',
|
||||
repeat: 'Wiederholen',
|
||||
repeatOff: 'Aus',
|
||||
repeatAll: 'Alle',
|
||||
repeatOne: 'Einen',
|
||||
progress: 'Songfortschritt',
|
||||
volume: 'Lautstärke',
|
||||
toggleQueue: 'Warteschlange umschalten',
|
||||
lyrics: 'Lyrics',
|
||||
fsLyricsToggle: 'Lyrics im Vollbild',
|
||||
lyricsLoading: 'Lyrics werden geladen…',
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
lyricsSourceServer: 'Quelle: Server',
|
||||
lyricsSourceLrclib: 'Quelle: LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Künstler',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album-Künstler',
|
||||
year: 'Jahr',
|
||||
genre: 'Genre',
|
||||
duration: 'Länge',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Abtastrate',
|
||||
bitDepth: 'Bittiefe',
|
||||
channels: 'Kanäle',
|
||||
fileSize: 'Dateigröße',
|
||||
path: 'Speicherort',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Neue Playlist',
|
||||
unnamed: 'Unbenannte Playlist',
|
||||
createName: 'Playlist-Name…',
|
||||
create: 'Erstellen',
|
||||
cancel: 'Abbrechen',
|
||||
empty: 'Noch keine Playlists.',
|
||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||
addFirstSong: 'Ersten Song hinzufügen',
|
||||
notFound: 'Playlist nicht gefunden.',
|
||||
songs: '{{n}} Songs',
|
||||
playAll: 'Alle abspielen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
addToQueue: 'Zur Warteschlange',
|
||||
back: 'Zurück zu Playlists',
|
||||
deletePlaylist: 'Löschen',
|
||||
confirmDelete: 'Nochmals klicken zum Bestätigen',
|
||||
removeSong: 'Aus Playlist entfernen',
|
||||
addSongs: 'Songs hinzufügen',
|
||||
searchPlaceholder: 'Bibliothek durchsuchen…',
|
||||
noResults: 'Keine Ergebnisse.',
|
||||
suggestions: 'Vorgeschlagene Songs',
|
||||
noSuggestions: 'Keine Vorschläge verfügbar.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
publicLabel: 'Öffentlich',
|
||||
privateLabel: 'Privat',
|
||||
editMeta: 'Playlist bearbeiten',
|
||||
editNamePlaceholder: 'Playlist-Name…',
|
||||
editCommentPlaceholder: 'Beschreibung hinzufügen…',
|
||||
editPublic: 'Öffentliche Playlist',
|
||||
editSave: 'Speichern',
|
||||
editCancel: 'Abbrechen',
|
||||
changeCover: 'Coverbild ändern',
|
||||
changeCoverLabel: 'Foto ändern',
|
||||
removeCover: 'Foto entfernen',
|
||||
coverUpdated: 'Cover aktualisiert',
|
||||
metaSaved: 'Playlist aktualisiert',
|
||||
downloadZip: 'Herunterladen (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meistgehört',
|
||||
topArtists: 'Top-Künstler',
|
||||
topAlbums: 'Top-Alben',
|
||||
plays: '{{n}}× gespielt',
|
||||
sortMost: 'Meiste Plays zuerst',
|
||||
sortLeast: 'Wenigste Plays zuerst',
|
||||
loadMore: 'Mehr Alben laden',
|
||||
noData: 'Noch keine Wiedergabedaten. Fang an zu hören!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
empty: 'Keine Radiosender konfiguriert.',
|
||||
addStation: 'Sender hinzufügen',
|
||||
editStation: 'Bearbeiten',
|
||||
deleteStation: 'Sender löschen',
|
||||
confirmDelete: 'Zum Bestätigen erneut klicken',
|
||||
stationName: 'Sendername…',
|
||||
streamUrl: 'Stream-URL…',
|
||||
homepageUrl: 'Homepage-URL (optional)',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage öffnen',
|
||||
changeCoverLabel: 'Cover ändern',
|
||||
removeCover: 'Cover entfernen',
|
||||
browseDirectory: 'Verzeichnis durchsuchen',
|
||||
directoryPlaceholder: 'Sender suchen…',
|
||||
noResults: 'Keine Sender gefunden.',
|
||||
stationAdded: 'Sender hinzugefügt',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favoriten',
|
||||
sortManual: 'Manuell',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Neueste',
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,825 @@
|
||||
export const enTranslation = {
|
||||
sidebar: {
|
||||
library: 'Library',
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'New Releases',
|
||||
allAlbums: 'All Albums',
|
||||
randomAlbums: 'Random Albums',
|
||||
artists: 'Artists',
|
||||
randomMix: 'Random Mix',
|
||||
favorites: 'Favorites',
|
||||
nowPlaying: 'Now Playing',
|
||||
system: 'System',
|
||||
statistics: 'Statistics',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Personal Favorites',
|
||||
recent: 'Recently Added',
|
||||
mostPlayed: 'Most Played',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
discover: 'Discover',
|
||||
loadMore: 'Load More',
|
||||
discoverMore: 'Discover More',
|
||||
discoverArtists: 'Discover Artists',
|
||||
discoverArtistsMore: 'All Artists'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Featured Album',
|
||||
playAlbum: 'Play Album',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add entire album to queue',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search for artist, album or song…',
|
||||
noResults: 'No results for "{{query}}"',
|
||||
artists: 'Artists',
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Clear search',
|
||||
title: 'Search',
|
||||
resultsFor: 'Results for "{{query}}"',
|
||||
album: 'Album',
|
||||
advanced: 'Advanced Search',
|
||||
advancedSearchTerm: 'Search term',
|
||||
advancedSearchPlaceholder: 'Title, album, artist…',
|
||||
advancedGenre: 'Genre',
|
||||
advancedAllGenres: 'All genres',
|
||||
advancedYear: 'Year',
|
||||
advancedYearFrom: 'from',
|
||||
advancedYearTo: 'to',
|
||||
advancedAll: 'All',
|
||||
advancedSearch: 'Search',
|
||||
advancedEmpty: 'Enter a search term or select a filter to begin.',
|
||||
advancedNoResults: 'No results found.',
|
||||
advancedGenreNote: 'Songs are randomly selected from this genre.',
|
||||
recentSearches: 'Recent Searches',
|
||||
browse: 'Browse',
|
||||
emptyHint: 'What do you want to hear?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Who is listening?',
|
||||
title: 'Who is listening?',
|
||||
loading: 'Loading…',
|
||||
nobody: 'Nobody is currently listening.',
|
||||
minutesAgo: '{{n}}m ago',
|
||||
nothingPlaying: 'Nothing playing yet. Start a track!',
|
||||
aboutArtist: 'About the Artist',
|
||||
fromAlbum: 'From this Album',
|
||||
viewAlbum: 'View Album',
|
||||
goToArtist: 'Go to Artist',
|
||||
readMore: 'Read more',
|
||||
showLess: 'Show less',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Track Info',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
playNext: 'Play Next',
|
||||
addToQueue: 'Add to Queue',
|
||||
enqueueAlbum: 'Enqueue Album',
|
||||
startRadio: 'Start Radio',
|
||||
lfmLove: 'Love on Last.fm',
|
||||
lfmUnlove: 'Unlove on Last.fm',
|
||||
favorite: 'Favorite',
|
||||
favoriteArtist: 'Favorite Artist',
|
||||
favoriteAlbum: 'Favorite Album',
|
||||
unfavorite: 'Remove from Favorites',
|
||||
unfavoriteArtist: 'Remove Artist from Favorites',
|
||||
unfavoriteAlbum: 'Remove Album from Favorites',
|
||||
removeFromQueue: 'Remove from Queue',
|
||||
openAlbum: 'Open Album',
|
||||
goToArtist: 'Go to Artist',
|
||||
download: 'Download (ZIP)',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
songInfo: 'Song Info',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Back',
|
||||
playAll: 'Play All',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add entire album to queue',
|
||||
artistBio: 'Artist Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Loading…',
|
||||
cacheOffline: 'Make available offline',
|
||||
offlineCached: 'Available offline',
|
||||
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
||||
removeOffline: 'Remove offline cache',
|
||||
offlineStorageFull: 'Offline storage full (limit: {{mb}} MB). Free up space by deleting an album from your Offline Library, or increase the limit in Settings.',
|
||||
offlineStorageGoToLibrary: 'Offline Library',
|
||||
offlineStorageGoToSettings: 'Settings',
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
favorite: 'Favorite',
|
||||
noBio: 'No biography available.',
|
||||
moreByArtist: 'More by {{artist}}',
|
||||
tracksCount: '{{n}} Tracks',
|
||||
goToArtist: 'Go to {{artist}}',
|
||||
moreLabelAlbums: 'More albums on {{label}}',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorite',
|
||||
trackRating: 'Rating',
|
||||
trackDuration: 'Duration',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Columns',
|
||||
notFound: 'Album not found.',
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Play All',
|
||||
shuffle: 'Shuffle',
|
||||
radio: 'Radio',
|
||||
loading: 'Loading…',
|
||||
noRadio: 'No similar tracks found for this artist.',
|
||||
notFound: 'Artist not found.',
|
||||
albumsBy: 'Albums by {{name}}',
|
||||
topTracks: 'Top Tracks',
|
||||
noAlbums: 'No albums found.',
|
||||
trackTitle: 'Title',
|
||||
trackAlbum: 'Album',
|
||||
trackDuration: 'Duration',
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
favorite: 'Favorite',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
similarArtists: 'Similar Artists',
|
||||
cacheOffline: 'Save discography offline',
|
||||
offlineCached: 'Discography cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
uploadImage: 'Upload artist image',
|
||||
uploadImageError: 'Failed to upload image',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
empty: "You haven't saved any favorites yet.",
|
||||
artists: 'Artists',
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Add all to queue',
|
||||
playAll: 'Play all',
|
||||
removeSong: 'Remove from favorites',
|
||||
stations: 'Radio Stations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
refresh: 'Refresh',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
genreCount: 'Genres',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
loading: 'Loading genres…',
|
||||
empty: 'No genres found.',
|
||||
albumsLoading: 'Loading albums…',
|
||||
albumsEmpty: 'No albums found for this genre.',
|
||||
loadMore: 'Load more',
|
||||
back: 'Back',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Random Mix',
|
||||
remix: 'Remix',
|
||||
remixTooltip: 'Load new random songs',
|
||||
playAll: 'Play All',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorite',
|
||||
trackDuration: 'Duration',
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
play: 'Play',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
||||
excludeAudiobooksDesc: 'Matches keywords against genre, title, album, and artist — e.g. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword blocked',
|
||||
genreAddedToBlacklist: 'Added to filter list',
|
||||
genreAlreadyBlocked: 'Already blocked',
|
||||
artistBlocked: 'Artist blocked',
|
||||
artistAddedToBlacklist: 'Artist added to filter list',
|
||||
artistClickHint: 'Click to block this artist',
|
||||
blacklistToggle: 'Keyword Filter',
|
||||
genreMixTitle: 'Genre Mix',
|
||||
genreMixDesc: 'Top 20 genres by song count — click to load a random mix',
|
||||
genreMixLoadMore: 'Load 10 more',
|
||||
genreMixNoGenres: 'No genres found on server.',
|
||||
shuffleGenres: 'Show different genres',
|
||||
filterPanelTitle: 'Filters',
|
||||
filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.',
|
||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
|
||||
},
|
||||
albums: {
|
||||
title: 'All Albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (Artist)',
|
||||
sortNewest: 'Newest first',
|
||||
sortRandom: 'Random',
|
||||
yearFrom: 'From',
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artists',
|
||||
search: 'Search…',
|
||||
all: 'All',
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
imagesOn: 'Artist images on — may increase network and system load',
|
||||
imagesOff: 'Artist images off — showing initials only',
|
||||
loadMore: 'Load more',
|
||||
notFound: 'No artists found.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Your Navidrome Desktop Player',
|
||||
serverName: 'Server Name (optional)',
|
||||
serverNamePlaceholder: 'My Navidrome',
|
||||
serverUrl: 'Server URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Password',
|
||||
showPassword: 'Show password',
|
||||
hidePassword: 'Hide password',
|
||||
connect: 'Connect',
|
||||
connecting: 'Connecting…',
|
||||
connected: 'Connected!',
|
||||
error: 'Connection failed – please check your details.',
|
||||
urlRequired: 'Please enter a server URL.',
|
||||
savedServers: 'Saved Servers',
|
||||
addNew: 'Or add a new server',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Connected',
|
||||
connectedTo: 'Connected to {{server}}',
|
||||
disconnected: 'Disconnected',
|
||||
disconnectedFrom: 'Cannot reach {{server}} — click to check settings',
|
||||
checking: 'Connecting…',
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'No server connection',
|
||||
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
|
||||
offlineModeBanner: 'Offline Mode — playing from local cache',
|
||||
offlineLibraryTitle: 'Offline Library',
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'All',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
},
|
||||
common: {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
loading: 'Loading…',
|
||||
loadingMore: 'Loading…',
|
||||
loadingPlaylists: 'Loading Playlists…',
|
||||
noAlbums: 'No albums found.',
|
||||
downloading: 'Downloading…',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Back',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
use: 'Use',
|
||||
add: 'Add',
|
||||
active: 'Active',
|
||||
download: 'Download',
|
||||
chooseDownloadFolder: 'Choose download folder',
|
||||
noFolderSelected: 'No folder selected',
|
||||
rememberDownloadFolder: 'Remember this folder',
|
||||
filterGenre: 'Genre Filter',
|
||||
filterSearchGenres: 'Search genres…',
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
bulkSelected: '{{count}} selected',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is available',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
language: 'Language',
|
||||
languageEn: 'English',
|
||||
languageDe: 'German',
|
||||
languageFr: 'French',
|
||||
languageNl: 'Dutch',
|
||||
languageZh: 'Chinese',
|
||||
languageNb: 'Norwegian',
|
||||
languageRu: 'Russian',
|
||||
font: 'Font',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
servers: 'Servers',
|
||||
serverName: 'Server Name',
|
||||
serverUrl: 'Server URL',
|
||||
serverUsername: 'Username',
|
||||
serverPassword: 'Password',
|
||||
addServer: 'Add Server',
|
||||
addServerTitle: 'Add New Server',
|
||||
useServer: 'Use',
|
||||
deleteServer: 'Delete',
|
||||
noServers: 'No servers saved.',
|
||||
serverActive: 'Active',
|
||||
confirmDeleteServer: 'Delete server "{{name}}"?',
|
||||
serverConnecting: 'Connecting…',
|
||||
serverConnected: 'Connected!',
|
||||
serverFailed: 'Connection failed.',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
eqEnabled: 'Enable Equalizer',
|
||||
eqPreset: 'Preset',
|
||||
eqPresetCustom: 'Custom',
|
||||
eqPresetBuiltin: 'Built-in Presets',
|
||||
eqPresetCustomGroup: 'My Presets',
|
||||
eqSavePreset: 'Save as Preset',
|
||||
eqPresetName: 'Preset name…',
|
||||
eqDeletePreset: 'Delete preset',
|
||||
eqResetBands: 'Reset to Flat',
|
||||
eqPreGain: 'Pre-gain',
|
||||
eqResetPreGain: 'Reset pre-gain',
|
||||
eqAutoEqTitle: 'AutoEQ Headphone Lookup',
|
||||
eqAutoEqPlaceholder: 'Search headphone / IEM model…',
|
||||
eqAutoEqSearching: 'Searching…',
|
||||
eqAutoEqNoResults: 'No results found',
|
||||
eqAutoEqError: 'Search failed',
|
||||
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
|
||||
eqAutoEqFetchError: 'Failed to fetch EQ profile',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Connect with Last.fm',
|
||||
lfmConnecting: 'Waiting for authorisation…',
|
||||
lfmConfirm: 'I have authorised the app',
|
||||
lfmConnected: 'Connected as',
|
||||
lfmDisconnect: 'Disconnect',
|
||||
lfmConnectDesc: 'Connect your Last.fm account to enable scrobbling and Now Playing updates directly from Psysonic — no Navidrome configuration required.',
|
||||
lfmOpenBrowser: 'A browser window will open. Authorise Psysonic on Last.fm, then click the button below.',
|
||||
lfmScrobbles: '{{n}} scrobbles',
|
||||
lfmMemberSince: 'Member since {{year}}',
|
||||
scrobbleEnabled: 'Scrobbling enabled',
|
||||
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
||||
behavior: 'App Behavior',
|
||||
cacheTitle: 'Max. Storage Size',
|
||||
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
||||
cacheUsedImages: 'Images:',
|
||||
cacheUsedOffline: 'Offline tracks:',
|
||||
cacheUsedHot: 'Size on disk:',
|
||||
hotCacheTrackCount: 'Tracks in cache:',
|
||||
cacheMaxLabel: 'Max. size',
|
||||
cacheClearBtn: 'Clear Cache',
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
offlineDirTitle: 'Offline Library (In-App)',
|
||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||
offlineDirDefault: 'Default (App Data)',
|
||||
offlineDirChange: 'Change Directory',
|
||||
offlineDirClear: 'Reset to Default',
|
||||
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
|
||||
hotCacheTitle: 'Hot playback cache',
|
||||
hotCacheAlphaBadge: 'Alpha',
|
||||
hotCacheDisclaimer: 'Preloads upcoming queue tracks and keeps previous ones. When enabled, uses disk space and network.',
|
||||
hotCacheDirDefault: 'Default (App Data)',
|
||||
hotCacheDirChange: 'Change folder',
|
||||
hotCacheDirClear: 'Reset to default',
|
||||
hotCacheDirHint: 'Changing the folder resets the in-app index; files already on disk stay where they are until you remove them.',
|
||||
hotCacheEnabled: 'Enable hot playback cache',
|
||||
hotCacheMaxMb: 'Maximum cache size',
|
||||
hotCacheDebounce: 'Queue change debounce',
|
||||
hotCacheDebounceImmediate: 'Immediate',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Clear hot cache',
|
||||
hiResTitle: 'Native Hi-Res Playback',
|
||||
hiResEnabled: 'Enable native hi-res playback',
|
||||
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
showTrayIcon: 'Show Tray Icon',
|
||||
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
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.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
clearFolder: 'Clear download folder',
|
||||
logout: 'Logout',
|
||||
aboutTitle: 'About Psysonic',
|
||||
aboutDesc: 'A modern desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Built on Tauri v2 with a native Rust audio engine — lightweight and fast, yet packed with features: waveform seekbar, synchronized lyrics, Last.fm integration, 10-band EQ, crossfade, gapless playback, Replay Gain, genre browsing, and a large library of themes.',
|
||||
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Synced lyrics · 10-band EQ · Crossfade & Gapless · Replay Gain · Genres · 63 themes · 5 languages',
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Contributors',
|
||||
aboutSpecialThanksLabel: 'Special Thanks',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
|
||||
randomMixTitle: 'Random Mix',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
|
||||
randomMixBlacklistPlaceholder: 'Add keyword…',
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Storage & Downloads',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
tabInput: 'Input',
|
||||
tabServer: 'Server',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
backupImport: 'Import settings',
|
||||
backupImportDesc: 'Restores settings from a .psybkp file. The app will reload after import.',
|
||||
backupImportConfirm: 'This will overwrite all current settings. Continue?',
|
||||
backupSuccess: 'Backup saved',
|
||||
backupImportSuccess: 'Settings restored — reloading…',
|
||||
backupImportError: 'Invalid or corrupted backup file.',
|
||||
shortcutsReset: 'Reset to defaults',
|
||||
shortcutListening: 'Press a key…',
|
||||
shortcutUnbound: '—',
|
||||
globalShortcutsTitle: 'Global Shortcuts',
|
||||
globalShortcutsNote: 'Work system-wide even when Psysonic is in the background. Requires Ctrl, Alt, or Super as a modifier.',
|
||||
shortcutClear: 'Clear',
|
||||
shortcutPlayPause: 'Play / Pause',
|
||||
shortcutNext: 'Next track',
|
||||
shortcutPrev: 'Previous track',
|
||||
shortcutVolumeUp: 'Volume up',
|
||||
shortcutVolumeDown: 'Volume down',
|
||||
shortcutSeekForward: 'Seek forward 10s',
|
||||
shortcutSeekBackward: 'Seek backward 10s',
|
||||
shortcutToggleQueue: 'Toggle queue',
|
||||
shortcutFullscreenPlayer: 'Fullscreen player',
|
||||
shortcutNativeFullscreen: 'Native fullscreen',
|
||||
playbackTitle: 'Playback',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Not available while Gapless is active',
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
preloadBalanced: 'Balanced (30 s before end)',
|
||||
preloadEarly: 'Early (after 5 s of playback)',
|
||||
preloadCustom: 'Custom',
|
||||
preloadCustomSeconds: 'Seconds before end: {{n}}',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "What's New",
|
||||
dontShowAgain: "Don't show again",
|
||||
close: 'Got it',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
s1: 'Getting Started',
|
||||
q1: 'Which servers are compatible?',
|
||||
a1: 'Psysonic works with any Subsonic-compatible server: Navidrome, Gonic, Subsonic, Airsonic, and others. Navidrome is the recommended choice.',
|
||||
q2: 'How do I connect to my server?',
|
||||
a2: 'Open Settings and click "Add Server". Enter the server URL (e.g. 192.168.1.100:4533), your username, and password. Psysonic tests the connection before saving — nothing is stored if the connection fails.',
|
||||
q3: 'Can I use multiple servers?',
|
||||
a3: 'Yes. You can add as many servers as you like in Settings and switch between them at any time. Only one server is active at a time.',
|
||||
s2: 'Playback',
|
||||
q4: 'How do I play music?',
|
||||
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
|
||||
q5: 'What keyboard shortcuts are available?',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on all platforms. On Linux, use the player bar buttons for media keys. In-app shortcuts and system-wide global shortcuts can be customized in Settings → Keyboard Shortcuts / Global Shortcuts.',
|
||||
q6: 'What is the queue?',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
|
||||
q7: 'How do I open the fullscreen player?',
|
||||
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
|
||||
q8: 'How does repeat work?',
|
||||
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
|
||||
s3: 'Library',
|
||||
q9: 'How do I download an album?',
|
||||
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server compresses the album into a ZIP file first — for large albums or lossless files (FLAC / WAV) this can take a moment before the download actually starts. This is normal: the progress bar appears once the server finishes zipping and the transfer begins.',
|
||||
q10: 'How do I star / favorite tracks and albums?',
|
||||
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
|
||||
q11: 'What is the hero carousel on the home page?',
|
||||
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
|
||||
s4: 'Settings',
|
||||
q12: 'How do I change the theme?',
|
||||
a12: 'Settings → Theme. Choose from a large selection of themes across 8 groups: Psysonic Themes, Mediaplayer, Operating Systems, Games, Movies, Series, Social Media, and Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
|
||||
q13: 'How do I change the language?',
|
||||
a13: 'Settings → Language. English, German, French, Dutch, Chinese, Norwegian, and Russian are supported.',
|
||||
q15: 'How do I set a download folder?',
|
||||
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'How does scrobbling work?',
|
||||
a16: 'Psysonic scrobbles directly to Last.fm — no Navidrome configuration needed. Connect your Last.fm account in Settings → Last.fm and enable scrobbling there.',
|
||||
q17: 'When is a scrobble sent?',
|
||||
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
|
||||
q22: 'What is the waveform in the player bar?',
|
||||
a22: 'The waveform seekbar replaces the classic progress slider. Click anywhere on it to jump to that position, or drag to scrub. The played portion glows with a blue-to-mauve gradient, the buffered range appears slightly brighter, and the unplayed portion is faded.',
|
||||
q24: 'Can I shuffle the queue?',
|
||||
a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.',
|
||||
q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?',
|
||||
a25: 'Yes — they open in your default system browser. The button briefly shows a confirmation label when clicked.',
|
||||
s7: 'Random Mix',
|
||||
q26: 'What is Random Mix?',
|
||||
a26: 'Random Mix builds a playlist of random tracks from your entire library. Open it via "Random Mix" in the sidebar and click "New Mix". You can adjust the track count before generating.',
|
||||
q27: 'What is the Keyword Filter?',
|
||||
a27: 'The Keyword Filter excludes tracks whose genre, title, or album name contains specific words. Audiobooks are filtered automatically. Add custom keywords in Settings → Random Mix, or click any genre tag in the track list to add it instantly.',
|
||||
q28: 'What is the Super Genre Mix?',
|
||||
a28: 'The Super Genre Mix groups your library into broad categories (Rock, Metal, Electronic, Jazz, Classical, etc.) and builds a focused mix from that style. Select a category chip below the track list. Results appear progressively as each sub-genre is fetched.',
|
||||
s6: 'Troubleshooting',
|
||||
q18: 'Cover art and artist images load slowly.',
|
||||
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
|
||||
q19: 'The connection test fails.',
|
||||
a19: 'Check the URL including the port (e.g. http://192.168.1.100:4533). Make sure no firewall blocks the connection. Try http:// instead of https:// on a local network. Also verify that your username and password are correct.',
|
||||
q20: 'No audio on Linux.',
|
||||
a20: 'Psysonic is available as .deb (Ubuntu/Debian), .rpm (Fedora/RHEL), and via AUR on Arch/CachyOS (yay -S psysonic). There is no AppImage. If audio is missing, ensure PipeWire or PulseAudio is running.',
|
||||
q21: 'The app shows a black screen on Linux.',
|
||||
a21: 'This is usually a GPU/EGL driver issue in WebKitGTK. Launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. The AUR package and official .deb/.rpm installers set these automatically.',
|
||||
q29: 'What are Crossfade and Gapless Playback?',
|
||||
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (1–10 s) to taste.',
|
||||
q30: 'Does Psysonic show lyrics?',
|
||||
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
|
||||
q31: 'Can I customize keyboard shortcuts?',
|
||||
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
|
||||
q32: 'Can I change the font?',
|
||||
a32: 'Yes. Settings → Font lets you choose from 10 fonts including IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime, and others. The selected font applies to the entire UI.',
|
||||
s8: 'Offline Mode',
|
||||
q34: 'What is Offline Mode?',
|
||||
a34: 'Offline Mode (Beta) lets you cache albums to your device so you can listen without an active server connection. Cached tracks are stored locally and played back directly from disk — no network request is made during playback.',
|
||||
q35: 'How do I cache an album for offline use?',
|
||||
a35: 'Open any album and click the download icon in the album header. Psysonic downloads all tracks in the background. Progress is shown on the button. Once cached, the icon turns green. You can view and remove cached albums in the Offline Library page (sidebar).',
|
||||
q36: 'How much storage can offline caching use?',
|
||||
a36: 'You can set a maximum cache size in Settings → Library. When the limit is reached, a warning banner appears on the album page. You can delete individual albums from the Offline Library to free up space.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
savePlaylist: 'Save Playlist',
|
||||
updatePlaylist: 'Update Playlist',
|
||||
filterPlaylists: 'Filter playlists…',
|
||||
playlistName: 'Playlist Name',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
loadPlaylist: 'Load Playlist',
|
||||
loading: 'Loading…',
|
||||
noPlaylists: 'No playlists found.',
|
||||
load: 'Replace queue & play',
|
||||
appendToQueue: 'Append to queue',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||
clear: 'Clear',
|
||||
shuffle: 'Shuffle queue',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
autoAdded: '— Added automatically —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
showRemaining: 'Show remaining time',
|
||||
showTotal: 'Show total time',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
mostPlayed: 'Most Played Albums',
|
||||
highestRated: 'Highest Rated Albums',
|
||||
genreDistribution: 'Genre Distribution (Top 20)',
|
||||
loadMore: 'Load more',
|
||||
statArtists: 'Artists',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Total Playtime',
|
||||
genreInsights: 'Genre Insights',
|
||||
formatDistribution: 'Format Distribution',
|
||||
formatSample: 'Sample of {{n}} tracks',
|
||||
computing: 'Computing…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Albums',
|
||||
recentlyAdded: 'Recently Added',
|
||||
decadeDistribution: 'Albums by Decade',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Albums',
|
||||
decadeUnknown: 'Unknown',
|
||||
lfmTitle: 'Last.fm Stats',
|
||||
lfmTopArtists: 'Top Artists',
|
||||
lfmTopAlbums: 'Top Albums',
|
||||
lfmTopTracks: 'Top Tracks',
|
||||
lfmPlays: '{{count}} plays',
|
||||
lfmPeriodOverall: 'All Time',
|
||||
lfmPeriod7day: '7 Days',
|
||||
lfmPeriod1month: '1 Month',
|
||||
lfmPeriod3month: '3 Months',
|
||||
lfmPeriod6month: '6 Months',
|
||||
lfmPeriod12month: '12 Months',
|
||||
lfmNotConnected: 'Connect Last.fm in Settings to see your stats.',
|
||||
lfmRecentTracks: 'Recent Scrobbles',
|
||||
lfmNowPlaying: 'Now Playing',
|
||||
lfmJustNow: 'just now',
|
||||
lfmMinutesAgo: '{{n}}m ago',
|
||||
lfmHoursAgo: '{{n}}h ago',
|
||||
lfmDaysAgo: '{{n}}d ago',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
openFullscreen: 'Open Fullscreen Player',
|
||||
fullscreen: 'Fullscreen Player',
|
||||
closeFullscreen: 'Close Fullscreen',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
noTitle: 'No Title',
|
||||
stop: 'Stop',
|
||||
prev: 'Previous Track',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
next: 'Next Track',
|
||||
repeat: 'Repeat',
|
||||
repeatOff: 'Off',
|
||||
repeatAll: 'All',
|
||||
repeatOne: 'One',
|
||||
progress: 'Song Progress',
|
||||
volume: 'Volume',
|
||||
toggleQueue: 'Toggle Queue',
|
||||
lyrics: 'Lyrics',
|
||||
fsLyricsToggle: 'Lyrics in fullscreen',
|
||||
lyricsLoading: 'Loading lyrics…',
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
lyricsSourceServer: 'Source: Server',
|
||||
lyricsSourceLrclib: 'Source: LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
songTitle: 'Title',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album Artist',
|
||||
year: 'Year',
|
||||
genre: 'Genre',
|
||||
duration: 'Duration',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Sample Rate',
|
||||
bitDepth: 'Bit Depth',
|
||||
channels: 'Channels',
|
||||
fileSize: 'File Size',
|
||||
path: 'Path',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'New Playlist',
|
||||
unnamed: 'Unnamed Playlist',
|
||||
createName: 'Playlist name…',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No playlists yet.',
|
||||
emptyPlaylist: 'This playlist is empty.',
|
||||
addFirstSong: 'Add your first song',
|
||||
notFound: 'Playlist not found.',
|
||||
songs: '{{n}} songs',
|
||||
playAll: 'Play All',
|
||||
shuffle: 'Shuffle',
|
||||
addToQueue: 'Add to Queue',
|
||||
back: 'Back to Playlists',
|
||||
deletePlaylist: 'Delete',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
removeSong: 'Remove from playlist',
|
||||
addSongs: 'Add Songs',
|
||||
searchPlaceholder: 'Search your library…',
|
||||
noResults: 'No results.',
|
||||
suggestions: 'Suggested Songs',
|
||||
noSuggestions: 'No suggestions available.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Public',
|
||||
privateLabel: 'Private',
|
||||
editMeta: 'Edit playlist',
|
||||
editNamePlaceholder: 'Playlist name…',
|
||||
editCommentPlaceholder: 'Add a description…',
|
||||
editPublic: 'Public playlist',
|
||||
editSave: 'Save',
|
||||
editCancel: 'Cancel',
|
||||
changeCover: 'Change cover image',
|
||||
changeCoverLabel: 'Change photo',
|
||||
removeCover: 'Remove photo',
|
||||
coverUpdated: 'Cover updated',
|
||||
metaSaved: 'Playlist updated',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Most Played',
|
||||
topArtists: 'Top Artists',
|
||||
topAlbums: 'Top Albums',
|
||||
plays: '{{n}} plays',
|
||||
sortMost: 'Most plays first',
|
||||
sortLeast: 'Fewest plays first',
|
||||
loadMore: 'Load more albums',
|
||||
noData: 'No play data yet. Start listening!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
empty: 'No radio stations configured.',
|
||||
addStation: 'Add Station',
|
||||
editStation: 'Edit',
|
||||
deleteStation: 'Delete station',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
stationName: 'Station name…',
|
||||
streamUrl: 'Stream URL…',
|
||||
homepageUrl: 'Homepage URL (optional)',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internet Radio',
|
||||
openHomepage: 'Open homepage',
|
||||
changeCoverLabel: 'Change cover',
|
||||
removeCover: 'Remove cover',
|
||||
browseDirectory: 'Search Directory',
|
||||
directoryPlaceholder: 'Search stations…',
|
||||
noResults: 'No stations found.',
|
||||
stationAdded: 'Station added',
|
||||
filterAll: 'All',
|
||||
filterFavorites: 'Favorites',
|
||||
sortManual: 'Manual',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Newest',
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,824 @@
|
||||
export const frTranslation = {
|
||||
sidebar: {
|
||||
library: 'Bibliothèque',
|
||||
mainstage: 'Scène principale',
|
||||
newReleases: 'Nouveautés',
|
||||
allAlbums: 'Tous les albums',
|
||||
randomAlbums: 'Albums aléatoires',
|
||||
artists: 'Artistes',
|
||||
randomMix: 'Mix aléatoire',
|
||||
favorites: 'Favoris',
|
||||
nowPlaying: 'En cours',
|
||||
system: 'Système',
|
||||
statistics: 'Statistiques',
|
||||
settings: 'Paramètres',
|
||||
help: 'Aide',
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
starred: 'Favoris personnels',
|
||||
recent: 'Ajoutés récemment',
|
||||
mostPlayed: 'Les plus écoutés',
|
||||
recentlyPlayed: 'Récemment écoutés',
|
||||
discover: 'Découvrir',
|
||||
loadMore: 'Charger plus',
|
||||
discoverMore: 'Découvrir plus',
|
||||
discoverArtists: 'Découvrir des artistes',
|
||||
discoverArtistsMore: 'Tous les artistes'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Album en vedette',
|
||||
playAlbum: 'Lire l\'album',
|
||||
enqueue: 'Mettre en file',
|
||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Rechercher un artiste, album ou morceau…',
|
||||
noResults: 'Aucun résultat pour « {{query}} »',
|
||||
artists: 'Artistes',
|
||||
albums: 'Albums',
|
||||
songs: 'Morceaux',
|
||||
clearLabel: 'Effacer la recherche',
|
||||
title: 'Recherche',
|
||||
resultsFor: 'Résultats pour « {{query}} »',
|
||||
album: 'Album',
|
||||
advanced: 'Recherche avancée',
|
||||
advancedSearchTerm: 'Terme de recherche',
|
||||
advancedSearchPlaceholder: 'Titre, album, artiste…',
|
||||
advancedGenre: 'Genre',
|
||||
advancedAllGenres: 'Tous les genres',
|
||||
advancedYear: 'Année',
|
||||
advancedYearFrom: 'de',
|
||||
advancedYearTo: 'à',
|
||||
advancedAll: 'Tous',
|
||||
advancedSearch: 'Rechercher',
|
||||
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
|
||||
advancedNoResults: 'Aucun résultat trouvé.',
|
||||
advancedGenreNote: 'Les morceaux sont sélectionnés aléatoirement dans ce genre.',
|
||||
recentSearches: 'Recherches récentes',
|
||||
browse: 'Parcourir',
|
||||
emptyHint: 'Que veux-tu écouter ?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Qui écoute ?',
|
||||
title: 'Qui écoute ?',
|
||||
loading: 'Chargement…',
|
||||
nobody: 'Personne n\'écoute en ce moment.',
|
||||
minutesAgo: 'il y a {{n}} min',
|
||||
nothingPlaying: 'Rien en cours. Lancez un morceau !',
|
||||
aboutArtist: "À propos de l'artiste",
|
||||
fromAlbum: 'De cet album',
|
||||
viewAlbum: "Voir l'album",
|
||||
goToArtist: "Aller à l'artiste",
|
||||
readMore: 'Lire la suite',
|
||||
showLess: 'Réduire',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Infos piste',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Lire maintenant',
|
||||
playNext: 'Lire ensuite',
|
||||
addToQueue: 'Ajouter à la file',
|
||||
enqueueAlbum: 'Mettre l\'album en file',
|
||||
startRadio: 'Démarrer la radio',
|
||||
lfmLove: 'Aimer sur Last.fm',
|
||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||
favorite: 'Favori',
|
||||
favoriteArtist: 'Artiste favori',
|
||||
favoriteAlbum: 'Album favori',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
unfavoriteArtist: 'Retirer l\'artiste des favoris',
|
||||
unfavoriteAlbum: 'Retirer l\'album des favoris',
|
||||
removeFromQueue: 'Retirer de la file',
|
||||
openAlbum: 'Ouvrir l\'album',
|
||||
goToArtist: 'Aller à l\'artiste',
|
||||
download: 'Télécharger (ZIP)',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
songInfo: 'Infos du morceau',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Retour',
|
||||
playAll: 'Tout lire',
|
||||
enqueue: 'Mettre en file',
|
||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||
artistBio: 'Biographie',
|
||||
download: 'Télécharger (ZIP)',
|
||||
downloading: 'Chargement…',
|
||||
cacheOffline: 'Rendre disponible hors ligne',
|
||||
offlineCached: 'Disponible hors ligne',
|
||||
offlineDownloading: 'Mise en cache… ({{n}}/{{total}})',
|
||||
removeOffline: 'Supprimer le cache hors ligne',
|
||||
offlineStorageFull: 'Stockage hors ligne plein (limite : {{mb}} Mo). Supprimez un album de votre bibliothèque hors ligne ou augmentez la limite dans les paramètres.',
|
||||
offlineStorageGoToSettings: 'Paramètres',
|
||||
offlineStorageGoToLibrary: 'Bibliothèque hors ligne',
|
||||
favoriteAdd: 'Ajouter aux favoris',
|
||||
favoriteRemove: 'Retirer des favoris',
|
||||
favorite: 'Favori',
|
||||
noBio: 'Aucune biographie disponible.',
|
||||
moreByArtist: 'Plus de {{artist}}',
|
||||
tracksCount: '{{n}} pistes',
|
||||
goToArtist: 'Aller à {{artist}}',
|
||||
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
|
||||
trackTitle: 'Titre',
|
||||
trackArtist: 'Artiste',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favori',
|
||||
trackRating: 'Note',
|
||||
trackDuration: 'Durée',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Colonnes',
|
||||
notFound: 'Album introuvable.',
|
||||
bioModal: 'Biographie de l\'artiste',
|
||||
bioClose: 'Fermer',
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Tout lire',
|
||||
shuffle: 'Aléatoire',
|
||||
radio: 'Radio',
|
||||
loading: 'Chargement…',
|
||||
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
|
||||
notFound: 'Artiste introuvable.',
|
||||
albumsBy: 'Albums de {{name}}',
|
||||
topTracks: 'Morceaux populaires',
|
||||
noAlbums: 'Aucun album trouvé.',
|
||||
trackTitle: 'Titre',
|
||||
trackAlbum: 'Album',
|
||||
trackDuration: 'Durée',
|
||||
favoriteAdd: 'Ajouter aux favoris',
|
||||
favoriteRemove: 'Retirer des favoris',
|
||||
favorite: 'Favori',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
openedInBrowser: 'Ouvert dans le navigateur',
|
||||
featuredOn: 'Également sur',
|
||||
similarArtists: 'Artistes similaires',
|
||||
cacheOffline: 'Enregistrer la discographie hors ligne',
|
||||
offlineCached: 'Discographie en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
uploadImage: "Téléverser l'image de l'artiste",
|
||||
uploadImageError: "Échec du téléversement de l'image",
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoris',
|
||||
empty: 'Vous n\'avez pas encore de favoris.',
|
||||
artists: 'Artistes',
|
||||
albums: 'Albums',
|
||||
songs: 'Morceaux',
|
||||
enqueueAll: 'Tout ajouter à la file',
|
||||
playAll: 'Tout lire',
|
||||
removeSong: 'Retirer des favoris',
|
||||
stations: 'Stations de radio',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Albums aléatoires',
|
||||
refresh: 'Actualiser',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
genreCount: 'genres',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
loading: 'Chargement des genres…',
|
||||
empty: 'Aucun genre trouvé.',
|
||||
albumsLoading: 'Chargement des albums…',
|
||||
albumsEmpty: 'Aucun album trouvé pour ce genre.',
|
||||
loadMore: 'Charger plus',
|
||||
back: 'Retour',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Mix aléatoire',
|
||||
remix: 'Remixer',
|
||||
remixTooltip: 'Charger de nouveaux morceaux aléatoires',
|
||||
playAll: 'Tout lire',
|
||||
trackTitle: 'Titre',
|
||||
trackArtist: 'Artiste',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favori',
|
||||
trackDuration: 'Durée',
|
||||
favoriteAdd: 'Ajouter aux favoris',
|
||||
favoriteRemove: 'Retirer des favoris',
|
||||
play: 'Lire',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques',
|
||||
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre, l\'album et l\'artiste — ex. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Mot-clé bloqué',
|
||||
genreAddedToBlacklist: 'Ajouté à la liste de filtres',
|
||||
genreAlreadyBlocked: 'Déjà bloqué',
|
||||
artistBlocked: 'Artiste bloqué',
|
||||
artistAddedToBlacklist: 'Artiste ajouté à la liste de filtres',
|
||||
artistClickHint: 'Cliquer pour bloquer cet artiste',
|
||||
blacklistToggle: 'Filtre par mot-clé',
|
||||
genreMixTitle: 'Mix par genre',
|
||||
genreMixDesc: 'Top 20 genres par nombre de morceaux — cliquer pour un mix aléatoire',
|
||||
genreMixLoadMore: 'Charger 10 de plus',
|
||||
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
|
||||
shuffleGenres: 'Afficher d\'autres genres',
|
||||
filterPanelTitle: 'Filtres',
|
||||
filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.',
|
||||
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Tous les albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (Artiste)',
|
||||
sortNewest: 'Plus récents',
|
||||
sortRandom: 'Aléatoire',
|
||||
yearFrom: 'De',
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistes',
|
||||
search: 'Rechercher…',
|
||||
all: 'Tous',
|
||||
gridView: 'Vue en grille',
|
||||
listView: 'Vue en liste',
|
||||
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
|
||||
imagesOff: 'Images d\'artistes désactivées — affichage des initiales uniquement',
|
||||
loadMore: 'Charger plus',
|
||||
notFound: 'Aucun artiste trouvé.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Votre lecteur de bureau Navidrome',
|
||||
serverName: 'Nom du serveur (facultatif)',
|
||||
serverNamePlaceholder: 'Mon Navidrome',
|
||||
serverUrl: 'URL du serveur',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 ou music.exemple.com',
|
||||
username: 'Nom d\'utilisateur',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Mot de passe',
|
||||
showPassword: 'Afficher le mot de passe',
|
||||
hidePassword: 'Masquer le mot de passe',
|
||||
connect: 'Se connecter',
|
||||
connecting: 'Connexion…',
|
||||
connected: 'Connecté !',
|
||||
error: 'Connexion échouée — vérifiez vos informations.',
|
||||
urlRequired: 'Veuillez saisir une URL de serveur.',
|
||||
savedServers: 'Serveurs enregistrés',
|
||||
addNew: 'Ou ajouter un nouveau serveur',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Connecté',
|
||||
connectedTo: 'Connecté à {{server}}',
|
||||
disconnected: 'Déconnecté',
|
||||
disconnectedFrom: 'Impossible d\'atteindre {{server}} — cliquez pour vérifier les paramètres',
|
||||
checking: 'Connexion…',
|
||||
extern: 'Externe',
|
||||
offlineTitle: 'Pas de connexion au serveur',
|
||||
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
|
||||
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
|
||||
offlineLibraryTitle: 'Bibliothèque hors ligne',
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Tout',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
},
|
||||
common: {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
loading: 'Chargement…',
|
||||
loadingMore: 'Chargement…',
|
||||
loadingPlaylists: 'Chargement des listes…',
|
||||
noAlbums: 'Aucun album trouvé.',
|
||||
downloading: 'Téléchargement…',
|
||||
downloadZip: 'Télécharger (ZIP)',
|
||||
back: 'Retour',
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
delete: 'Supprimer',
|
||||
use: 'Utiliser',
|
||||
add: 'Ajouter',
|
||||
active: 'Actif',
|
||||
download: 'Télécharger',
|
||||
chooseDownloadFolder: 'Choisir le dossier de téléchargement',
|
||||
noFolderSelected: 'Aucun dossier sélectionné',
|
||||
rememberDownloadFolder: 'Mémoriser ce dossier',
|
||||
filterGenre: 'Filtre genre',
|
||||
filterSearchGenres: 'Rechercher des genres…',
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est disponible',
|
||||
updaterWebsite: 'Site Web',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
language: 'Langue',
|
||||
languageEn: 'Anglais',
|
||||
languageDe: 'Allemand',
|
||||
languageFr: 'Français',
|
||||
languageNl: 'Néerlandais',
|
||||
languageZh: 'Chinois',
|
||||
languageNb: 'Norvégien',
|
||||
languageRu: 'Russe',
|
||||
font: 'Police',
|
||||
theme: 'Thème',
|
||||
appearance: 'Apparence',
|
||||
servers: 'Serveurs',
|
||||
serverName: 'Nom du serveur',
|
||||
serverUrl: 'URL du serveur',
|
||||
serverUsername: 'Nom d\'utilisateur',
|
||||
serverPassword: 'Mot de passe',
|
||||
addServer: 'Ajouter un serveur',
|
||||
addServerTitle: 'Ajouter un nouveau serveur',
|
||||
useServer: 'Utiliser',
|
||||
deleteServer: 'Supprimer',
|
||||
noServers: 'Aucun serveur enregistré.',
|
||||
serverActive: 'Actif',
|
||||
confirmDeleteServer: 'Supprimer le serveur « {{name}} » ?',
|
||||
serverConnecting: 'Connexion…',
|
||||
serverConnected: 'Connecté !',
|
||||
serverFailed: 'Connexion échouée.',
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Connecté',
|
||||
failed: 'Échec',
|
||||
eqTitle: 'Égaliseur',
|
||||
eqEnabled: 'Activer l\'égaliseur',
|
||||
eqPreset: 'Préréglage',
|
||||
eqPresetCustom: 'Personnalisé',
|
||||
eqPresetBuiltin: 'Préréglages intégrés',
|
||||
eqPresetCustomGroup: 'Mes préréglages',
|
||||
eqSavePreset: 'Enregistrer comme préréglage',
|
||||
eqPresetName: 'Nom du préréglage…',
|
||||
eqDeletePreset: 'Supprimer le préréglage',
|
||||
eqResetBands: 'Réinitialiser à plat',
|
||||
eqPreGain: 'Pré-amplification',
|
||||
eqResetPreGain: 'Réinitialiser la pré-amplification',
|
||||
eqAutoEqTitle: 'Recherche AutoEQ',
|
||||
eqAutoEqPlaceholder: 'Rechercher un casque / IEM…',
|
||||
eqAutoEqSearching: 'Recherche…',
|
||||
eqAutoEqNoResults: 'Aucun résultat',
|
||||
eqAutoEqError: 'Échec de la recherche',
|
||||
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
|
||||
eqAutoEqFetchError: 'Impossible de charger le profil EQ',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Connecter avec Last.fm',
|
||||
lfmConnecting: 'En attente d\'autorisation…',
|
||||
lfmConfirm: 'J\'ai autorisé l\'application',
|
||||
lfmConnected: 'Connecté en tant que',
|
||||
lfmDisconnect: 'Déconnecter',
|
||||
lfmConnectDesc: 'Connectez votre compte Last.fm pour activer le scrobbling et les mises à jour « En cours de lecture » depuis Psysonic — aucune configuration Navidrome requise.',
|
||||
lfmOpenBrowser: 'Une fenêtre de navigateur s\'ouvrira. Autorisez Psysonic sur Last.fm, puis cliquez sur le bouton ci-dessous.',
|
||||
lfmScrobbles: '{{n}} scrobbles',
|
||||
lfmMemberSince: 'Membre depuis {{year}}',
|
||||
scrobbleEnabled: 'Scrobbling activé',
|
||||
scrobbleDesc: 'Envoyer les morceaux à Last.fm après 50% d\'écoute',
|
||||
behavior: 'Comportement de l\'application',
|
||||
cacheTitle: 'Taille max. du stockage',
|
||||
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
||||
cacheUsedImages: 'Images :',
|
||||
cacheUsedOffline: 'Pistes hors ligne :',
|
||||
cacheUsedHot: 'Espace disque :',
|
||||
hotCacheTrackCount: 'Pistes en cache :',
|
||||
cacheMaxLabel: 'Taille max.',
|
||||
cacheClearBtn: 'Vider le cache',
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
offlineDirTitle: 'Bibliothèque hors ligne (intégrée)',
|
||||
offlineDirDesc: 'Emplacement de stockage des titres rendus disponibles hors ligne dans Psysonic.',
|
||||
offlineDirDefault: 'Par défaut (données d\'application)',
|
||||
offlineDirChange: 'Changer le dossier',
|
||||
offlineDirClear: 'Réinitialiser',
|
||||
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
|
||||
hotCacheTitle: 'Cache de lecture à chaud',
|
||||
hotCacheAlphaBadge: 'Alpha',
|
||||
hotCacheDisclaimer: 'Précharge les titres à venir dans la file et conserve les précédents. Si activé : espace disque et réseau.',
|
||||
hotCacheDirDefault: 'Par défaut (données d\'application)',
|
||||
hotCacheDirChange: 'Changer le dossier',
|
||||
hotCacheDirClear: 'Réinitialiser',
|
||||
hotCacheDirHint: 'Changer de dossier réinitialise l’index dans l’app ; les fichiers déjà écrits restent à l’ancien emplacement.',
|
||||
hotCacheEnabled: 'Activer le cache de lecture à chaud',
|
||||
hotCacheMaxMb: 'Taille maximale du cache',
|
||||
hotCacheDebounce: 'Délai après changement de file',
|
||||
hotCacheDebounceImmediate: 'Immédiat',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Vider le cache à chaud',
|
||||
hiResTitle: 'Lecture haute résolution native',
|
||||
hiResEnabled: 'Activer la lecture haute résolution native',
|
||||
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
showTrayIcon: 'Afficher l\'icône dans la barre système',
|
||||
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.',
|
||||
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',
|
||||
discordAppleCoversDesc: 'Envoie le nom de l\'artiste et de l\'album à l\'API Apple pour trouver une pochette pour votre profil Discord. Désactivé par défaut pour des raisons de confidentialité.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
lyricsServerFirst: 'Préférer les paroles du serveur',
|
||||
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
pickFolderTitle: 'Sélectionner le dossier de téléchargement',
|
||||
clearFolder: 'Effacer le dossier',
|
||||
logout: 'Se déconnecter',
|
||||
aboutTitle: 'À propos de Psysonic',
|
||||
aboutDesc: 'Un lecteur de musique de bureau moderne pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Construit sur Tauri v2 avec un moteur audio Rust natif — léger et rapide, mais riche en fonctionnalités : barre waveform, paroles synchronisées, intégration Last.fm, égaliseur 10 bandes, crossfade, lecture sans blanc, Replay Gain, navigation par genre et une grande bibliothèque de thèmes.',
|
||||
aboutFeatures: 'Multi-serveur · Scrobbling & love Last.fm · Ambient Stage plein écran · Waveform seek · Paroles synchro · EQ 10 bandes · Crossfade & Gapless · Replay Gain · Genres · 63 thèmes · 5 langues',
|
||||
aboutLicense: 'Licence',
|
||||
aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.',
|
||||
aboutRepo: 'Code source sur GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||
aboutContributorsLabel: 'Contributeurs',
|
||||
aboutSpecialThanksLabel: 'Remerciements spéciaux',
|
||||
changelog: 'Journal des modifications',
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
|
||||
randomMixTitle: 'Mix aléatoire',
|
||||
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
||||
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
|
||||
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
|
||||
randomMixBlacklistAdd: 'Ajouter',
|
||||
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
|
||||
randomMixHardcodedTitle: 'Mots-clés intégrés (actifs quand la case est cochée)',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Stockage & Téléchargements',
|
||||
tabAppearance: 'Apparence',
|
||||
homeCustomizerTitle: 'Page d\'accueil',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
tabInput: 'Entrée',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
shortcutListening: 'Appuyez sur une touche…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Effacer',
|
||||
globalShortcutsTitle: 'Raccourcis globaux',
|
||||
globalShortcutsNote: 'Fonctionnent à l\'échelle du système, même quand Psysonic est en arrière-plan. Nécessite Ctrl, Alt ou Super comme modificateur.',
|
||||
shortcutPlayPause: 'Lecture / Pause',
|
||||
shortcutNext: 'Piste suivante',
|
||||
shortcutPrev: 'Piste précédente',
|
||||
shortcutVolumeUp: 'Volume +',
|
||||
shortcutVolumeDown: 'Volume -',
|
||||
shortcutSeekForward: 'Avancer de 10s',
|
||||
shortcutSeekBackward: 'Reculer de 10s',
|
||||
shortcutToggleQueue: 'Afficher/masquer la file',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
backupImport: 'Importer les paramètres',
|
||||
backupImportDesc: 'Restaure les paramètres depuis un fichier .psybkp. L\'application sera rechargée après l\'import.',
|
||||
backupImportConfirm: 'Tous les paramètres actuels seront écrasés. Continuer ?',
|
||||
backupSuccess: 'Sauvegarde enregistrée',
|
||||
backupImportSuccess: 'Paramètres restaurés — rechargement…',
|
||||
backupImportError: 'Fichier de sauvegarde invalide ou corrompu.',
|
||||
playbackTitle: 'Lecture',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normaliser le volume des pistes avec les métadonnées ReplayGain',
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Piste',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||
preloadEarly: 'Tôt (après 5 s de lecture)',
|
||||
preloadCustom: 'Personnalisé',
|
||||
preloadCustomSeconds: 'Secondes avant la fin : {{n}}',
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Quoi de neuf',
|
||||
dontShowAgain: 'Ne plus afficher',
|
||||
close: 'Compris',
|
||||
},
|
||||
help: {
|
||||
title: 'Aide',
|
||||
s1: 'Démarrage',
|
||||
q1: 'Quels serveurs sont compatibles ?',
|
||||
a1: 'Psysonic fonctionne avec tout serveur compatible Subsonic : Navidrome, Gonic, Subsonic, Airsonic, et autres. Navidrome est recommandé.',
|
||||
q2: 'Comment me connecter à mon serveur ?',
|
||||
a2: 'Ouvrez les Paramètres et cliquez sur « Ajouter un serveur ». Saisissez l\'URL du serveur (ex. 192.168.1.100:4533), votre nom d\'utilisateur et mot de passe. Psysonic teste la connexion avant d\'enregistrer.',
|
||||
q3: 'Puis-je utiliser plusieurs serveurs ?',
|
||||
a3: 'Oui. Vous pouvez ajouter autant de serveurs que vous le souhaitez dans les Paramètres et basculer entre eux à tout moment. Un seul serveur est actif à la fois.',
|
||||
s2: 'Lecture',
|
||||
q4: 'Comment lire de la musique ?',
|
||||
a4: 'Double-cliquez sur une piste pour la lire. Sur les pages d\'album et d\'artiste, utilisez « Tout lire » pour démarrer l\'album entier. Vous pouvez aussi glisser des pistes dans la file d\'attente.',
|
||||
q5: 'Quels raccourcis clavier sont disponibles ?',
|
||||
a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur toutes les plateformes. Sur Linux, utilisez les boutons de la barre du lecteur. Les raccourcis in-app et les raccourcis système peuvent être personnalisés dans Paramètres → Raccourcis / Raccourcis globaux.',
|
||||
q6: 'Qu\'est-ce que la file d\'attente ?',
|
||||
a6: 'La file d\'attente affiche toutes les pistes à venir. Ouvrez-la avec l\'icône de panneau en haut à droite. Vous pouvez réorganiser les pistes par glisser-déposer, les mélanger et enregistrer la file comme liste de lecture.',
|
||||
q7: 'Comment ouvrir le lecteur plein écran ?',
|
||||
a7: 'Cliquez sur la pochette dans la barre du lecteur en bas, ou sur l\'icône d\'agrandissement à côté. Appuyez sur Échap pour fermer.',
|
||||
q8: 'Comment fonctionne la répétition ?',
|
||||
a8: 'Cliquez sur le bouton répéter pour alterner : Désactivé → Répéter tout → Répéter un.',
|
||||
s3: 'Bibliothèque',
|
||||
q9: 'Comment télécharger un album ?',
|
||||
a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur compresse d\'abord l\'album en ZIP — pour les grands albums ou les fichiers sans perte (FLAC / WAV), cela peut prendre un moment avant que le téléchargement ne démarre réellement. La barre de progression n\'apparaît qu\'une fois la compression terminée.',
|
||||
q10: 'Comment mettre des pistes et albums en favoris ?',
|
||||
a10: 'Cliquez sur l\'icône étoile sur une piste ou dans l\'en-tête de l\'album. Les éléments favoris apparaissent dans la section Favoris de la barre latérale.',
|
||||
q11: 'Qu\'est-ce que le carousel hero sur la page d\'accueil ?',
|
||||
a11: 'La bannière en haut de la page d\'accueil sélectionne aléatoirement des albums de votre bibliothèque et les fait défiler toutes les 10 secondes. Cliquez sur les points pour accéder à un album spécifique.',
|
||||
s4: 'Paramètres',
|
||||
q12: 'Comment changer le thème ?',
|
||||
a12: 'Paramètres → Thème. Un grand choix de thèmes en 8 groupes : Psysonic Themes, Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries, Réseaux sociaux et Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
|
||||
q13: 'Comment changer la langue ?',
|
||||
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français, le néerlandais et le chinois sont pris en charge.',
|
||||
q15: 'Comment définir un dossier de téléchargement ?',
|
||||
a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'Comment fonctionne le scrobbling ?',
|
||||
a16: 'Psysonic envoie les scrobbles directement à Last.fm — aucune configuration Navidrome nécessaire. Connectez votre compte dans Paramètres → Last.fm et activez le scrobbling.',
|
||||
q17: 'Quand un scrobble est-il envoyé ?',
|
||||
a17: 'Un scrobble est soumis après avoir écouté 50% d\'une piste.',
|
||||
q22: 'Qu\'est-ce que la forme d\'onde dans la barre du lecteur ?',
|
||||
a22: 'La barre de forme d\'onde remplace le curseur de progression classique. Cliquez n\'importe où pour sauter à cette position, ou faites glisser pour naviguer.',
|
||||
q24: 'Puis-je mélanger la file d\'attente ?',
|
||||
a24: 'Oui. Ouvrez le panneau de file et cliquez sur l\'icône de mélange. La piste en cours reste en position 1 — les autres sont réorganisées aléatoirement.',
|
||||
q25: 'Les liens Last.fm et Wikipédia s\'ouvrent-ils dans un navigateur ?',
|
||||
a25: 'Oui — ils s\'ouvrent dans votre navigateur système par défaut. Le bouton affiche brièvement une confirmation au clic.',
|
||||
s7: 'Mix aléatoire',
|
||||
q26: 'Qu\'est-ce que le Mix aléatoire ?',
|
||||
a26: 'Le Mix aléatoire crée une liste de morceaux aléatoires depuis toute votre bibliothèque. Ouvrez-le via « Mix aléatoire » dans la barre latérale et cliquez sur « Nouveau mix ».',
|
||||
q27: 'Qu\'est-ce que le Filtre par mot-clé ?',
|
||||
a27: 'Le Filtre par mot-clé exclut les pistes dont le genre, le titre ou l\'album contient des mots spécifiques. Les livres audio sont filtrés automatiquement. Ajoutez des mots-clés dans Paramètres → Mix aléatoire.',
|
||||
q28: 'Qu\'est-ce que le Mix par super-genre ?',
|
||||
a28: 'Le Mix par super-genre regroupe votre bibliothèque en grandes catégories (Rock, Métal, Électronique, Jazz, Classique, etc.) et crée un mix ciblé. Sélectionnez une catégorie dans les puces sous la liste.',
|
||||
s6: 'Dépannage',
|
||||
q18: 'Les pochettes et images d\'artistes chargent lentement.',
|
||||
a18: 'Les images sont récupérées depuis le disque de votre serveur au premier chargement, puis mises en cache localement pendant 30 jours. Les visites suivantes seront instantanées.',
|
||||
q19: 'Le test de connexion échoue.',
|
||||
a19: 'Vérifiez l\'URL avec le port (ex. http://192.168.1.100:4533). Assurez-vous qu\'aucun pare-feu ne bloque la connexion. Essayez http:// au lieu de https:// sur un réseau local.',
|
||||
q20: 'Pas d\'audio sur Linux.',
|
||||
a20: 'Psysonic est disponible en .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) et via AUR sur Arch/CachyOS (yay -S psysonic). Il n\'y a pas d\'AppImage. Si l\'audio ne fonctionne pas, vérifiez que PipeWire ou PulseAudio est actif.',
|
||||
q21: 'L\'application affiche un écran noir sur Linux.',
|
||||
a21: 'Cela est généralement dû à des problèmes de pilote GPU/EGL dans WebKitGTK. Lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Les paquets AUR et installateurs .deb/.rpm officiels le configurent automatiquement.',
|
||||
q29: 'Que sont le fondu enchaîné et la lecture sans blanc ?',
|
||||
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (1–10 s).',
|
||||
q30: 'Psysonic affiche-t-il les paroles ?',
|
||||
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
|
||||
q31: 'Puis-je personnaliser les raccourcis clavier ?',
|
||||
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
|
||||
q32: 'Puis-je changer la police ?',
|
||||
a32: 'Oui. Paramètres → Police propose 10 polices dont IBM Plex Mono, Fira Code, JetBrains Mono et Courier Prime. La police choisie s\'applique à toute l\'interface.',
|
||||
s8: 'Mode hors ligne',
|
||||
q34: 'Qu\'est-ce que le mode hors ligne ?',
|
||||
a34: 'Le mode hors ligne (Bêta) permet de mettre des albums en cache sur l\'appareil afin d\'écouter de la musique sans connexion serveur active. Les pistes mises en cache sont stockées localement et lues directement depuis le disque — sans requête réseau.',
|
||||
q35: 'Comment mettre un album en cache pour une utilisation hors ligne ?',
|
||||
a35: 'Ouvrez un album et cliquez sur l\'icône de téléchargement dans l\'en-tête de l\'album. Psysonic télécharge toutes les pistes en arrière-plan. La progression est affichée sur le bouton. Une fois en cache, l\'icône devient verte. Les albums mis en cache peuvent être consultés et supprimés dans la page Bibliothèque hors ligne (barre latérale).',
|
||||
q36: 'Quelle quantité de stockage le cache hors ligne peut-il utiliser ?',
|
||||
a36: 'Vous pouvez définir une taille de cache maximale dans Paramètres → Bibliothèque. Lorsque la limite est atteinte, une bannière d\'avertissement s\'affiche sur la page de l\'album. Vous pouvez supprimer des albums individuels dans la Bibliothèque hors ligne pour libérer de l\'espace.',
|
||||
},
|
||||
queue: {
|
||||
title: 'File d\'attente',
|
||||
savePlaylist: 'Enregistrer la liste',
|
||||
updatePlaylist: 'Mettre à jour la liste',
|
||||
filterPlaylists: 'Filtrer les listes…',
|
||||
playlistName: 'Nom de la liste',
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
loadPlaylist: 'Charger une liste',
|
||||
loading: 'Chargement…',
|
||||
noPlaylists: 'Aucune liste trouvée.',
|
||||
load: 'Remplacer la file et lire',
|
||||
appendToQueue: 'Ajouter à la file',
|
||||
delete: 'Supprimer',
|
||||
deleteConfirm: 'Supprimer la liste « {{name}} » ?',
|
||||
clear: 'Vider',
|
||||
shuffle: 'Mélanger la file',
|
||||
gapless: 'Sans blanc',
|
||||
crossfade: 'Fondu',
|
||||
infiniteQueue: 'File infinie',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
emptyQueue: 'La file d\'attente est vide.',
|
||||
trackSingular: 'piste',
|
||||
trackPlural: 'pistes',
|
||||
showRemaining: 'Afficher le temps restant',
|
||||
showTotal: 'Afficher la durée totale',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiques',
|
||||
recentlyPlayed: 'Écoutés récemment',
|
||||
mostPlayed: 'Albums les plus écoutés',
|
||||
highestRated: 'Albums les mieux notés',
|
||||
genreDistribution: 'Répartition par genre (Top 20)',
|
||||
loadMore: 'Charger plus',
|
||||
statArtists: 'Artistes',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Morceaux',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Durée totale',
|
||||
genreInsights: 'Aperçu des genres',
|
||||
formatDistribution: 'Distribution des formats',
|
||||
formatSample: 'Échantillon de {{n}} pistes',
|
||||
computing: 'Calcul en cours…',
|
||||
genreSongs: '{{count}} morceaux',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Ajoutés récemment',
|
||||
decadeDistribution: 'Albums par décennie',
|
||||
decadeAlbums_one: '{{count}} album',
|
||||
decadeAlbums_other: '{{count}} albums',
|
||||
decadeUnknown: 'Inconnu',
|
||||
lfmTitle: 'Stats Last.fm',
|
||||
lfmTopArtists: 'Artistes favoris',
|
||||
lfmTopAlbums: 'Albums favoris',
|
||||
lfmTopTracks: 'Morceaux favoris',
|
||||
lfmPlays: '{{count}} écoutes',
|
||||
lfmPeriodOverall: 'Tout le temps',
|
||||
lfmPeriod7day: '7 jours',
|
||||
lfmPeriod1month: '1 mois',
|
||||
lfmPeriod3month: '3 mois',
|
||||
lfmPeriod6month: '6 mois',
|
||||
lfmPeriod12month: '12 mois',
|
||||
lfmNotConnected: 'Connectez Last.fm dans les Paramètres pour voir vos stats.',
|
||||
lfmRecentTracks: 'Scrobbles récents',
|
||||
lfmNowPlaying: 'En cours de lecture',
|
||||
lfmJustNow: 'à l\'instant',
|
||||
lfmMinutesAgo: 'il y a {{n}} min',
|
||||
lfmHoursAgo: 'il y a {{n}}h',
|
||||
lfmDaysAgo: 'il y a {{n}}j',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Lecteur de musique',
|
||||
openFullscreen: 'Ouvrir le lecteur plein écran',
|
||||
fullscreen: 'Lecteur plein écran',
|
||||
closeFullscreen: 'Fermer le plein écran',
|
||||
closeTooltip: 'Fermer (Échap)',
|
||||
noTitle: 'Sans titre',
|
||||
stop: 'Arrêter',
|
||||
prev: 'Piste précédente',
|
||||
play: 'Lecture',
|
||||
pause: 'Pause',
|
||||
next: 'Piste suivante',
|
||||
repeat: 'Répéter',
|
||||
repeatOff: 'Désactivé',
|
||||
repeatAll: 'Tout',
|
||||
repeatOne: 'Un',
|
||||
progress: 'Progression',
|
||||
volume: 'Volume',
|
||||
toggleQueue: 'Afficher/masquer la file',
|
||||
lyrics: 'Paroles',
|
||||
fsLyricsToggle: 'Paroles en plein écran',
|
||||
lyricsLoading: 'Chargement des paroles…',
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
lyricsSourceServer: 'Source : Serveur',
|
||||
lyricsSourceLrclib: 'Source : LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
songTitle: 'Titre',
|
||||
artist: 'Artiste',
|
||||
album: 'Album',
|
||||
albumArtist: 'Artiste de l\'album',
|
||||
year: 'Année',
|
||||
genre: 'Genre',
|
||||
duration: 'Durée',
|
||||
track: 'Piste',
|
||||
format: 'Format',
|
||||
bitrate: 'Débit',
|
||||
sampleRate: 'Fréquence d\'échantillonnage',
|
||||
bitDepth: 'Profondeur de bits',
|
||||
channels: 'Canaux',
|
||||
fileSize: 'Taille du fichier',
|
||||
path: 'Emplacement',
|
||||
replayGainTrack: 'RG Gain de piste',
|
||||
replayGainAlbum: 'RG Gain d\'album',
|
||||
replayGainPeak: 'RG Crête de piste',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stéréo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nouvelle playlist',
|
||||
unnamed: 'Playlist sans nom',
|
||||
createName: 'Nom de la playlist…',
|
||||
create: 'Créer',
|
||||
cancel: 'Annuler',
|
||||
empty: 'Aucune playlist pour l\'instant.',
|
||||
emptyPlaylist: 'Cette playlist est vide.',
|
||||
addFirstSong: 'Ajouter votre premier titre',
|
||||
notFound: 'Playlist introuvable.',
|
||||
songs: '{{n}} titres',
|
||||
playAll: 'Tout lire',
|
||||
shuffle: 'Aléatoire',
|
||||
addToQueue: 'Ajouter à la file',
|
||||
back: 'Retour aux playlists',
|
||||
deletePlaylist: 'Supprimer',
|
||||
confirmDelete: 'Cliquer à nouveau pour confirmer',
|
||||
removeSong: 'Retirer de la playlist',
|
||||
addSongs: 'Ajouter des titres',
|
||||
searchPlaceholder: 'Rechercher dans la bibliothèque…',
|
||||
noResults: 'Aucun résultat.',
|
||||
suggestions: 'Titres suggérés',
|
||||
noSuggestions: 'Aucune suggestion disponible.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Publique',
|
||||
privateLabel: 'Privée',
|
||||
editMeta: 'Modifier la playlist',
|
||||
editNamePlaceholder: 'Nom de la playlist…',
|
||||
editCommentPlaceholder: 'Ajouter une description…',
|
||||
editPublic: 'Playlist publique',
|
||||
editSave: 'Enregistrer',
|
||||
editCancel: 'Annuler',
|
||||
changeCover: 'Changer la pochette',
|
||||
changeCoverLabel: 'Changer la photo',
|
||||
removeCover: 'Supprimer la photo',
|
||||
coverUpdated: 'Pochette mise à jour',
|
||||
metaSaved: 'Playlist mise à jour',
|
||||
downloadZip: 'Télécharger (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Les plus joués',
|
||||
topArtists: 'Artistes populaires',
|
||||
topAlbums: 'Albums populaires',
|
||||
plays: '{{n}} écoutes',
|
||||
sortMost: 'Plus joués en premier',
|
||||
sortLeast: 'Moins joués en premier',
|
||||
loadMore: 'Charger plus d\'albums',
|
||||
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
|
||||
},
|
||||
radio: {
|
||||
title: 'Radio Internet',
|
||||
empty: 'Aucune station radio configurée.',
|
||||
addStation: 'Ajouter une station',
|
||||
editStation: 'Modifier',
|
||||
deleteStation: 'Supprimer la station',
|
||||
confirmDelete: 'Cliquer à nouveau pour confirmer',
|
||||
stationName: 'Nom de la station…',
|
||||
streamUrl: 'URL du flux…',
|
||||
homepageUrl: 'URL de la page d\'accueil (optionnel)',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Radio Internet',
|
||||
openHomepage: 'Ouvrir la page d\'accueil',
|
||||
changeCoverLabel: 'Changer la pochette',
|
||||
removeCover: 'Supprimer la pochette',
|
||||
browseDirectory: 'Parcourir l\'annuaire',
|
||||
directoryPlaceholder: 'Rechercher des stations…',
|
||||
noResults: 'Aucune station trouvée.',
|
||||
stationAdded: 'Station ajoutée',
|
||||
filterAll: 'Toutes',
|
||||
filterFavorites: 'Favoris',
|
||||
sortManual: 'Manuel',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Plus récentes',
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,823 @@
|
||||
export const nbTranslation = {
|
||||
sidebar: {
|
||||
library: 'Bibliotek',
|
||||
mainstage: 'Hovedscene',
|
||||
newReleases: 'Nye utgivelser',
|
||||
allAlbums: 'Alle album',
|
||||
randomAlbums: 'Tilfeldige album',
|
||||
artists: 'Artister',
|
||||
randomMix: 'Tilfeldig miks',
|
||||
favorites: 'Favoritter',
|
||||
nowPlaying: 'Spilles nå',
|
||||
system: 'System',
|
||||
statistics: 'Statistikk',
|
||||
settings: 'Innstillinger',
|
||||
help: 'Hjelp',
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
},
|
||||
home: {
|
||||
hero: 'Utvalgt',
|
||||
starred: 'Personlige favoritter',
|
||||
recent: 'Nylig lagt til',
|
||||
mostPlayed: 'Mest spilt',
|
||||
recentlyPlayed: 'Nylig spilt',
|
||||
discover: 'Oppdag',
|
||||
loadMore: 'Last inn flere',
|
||||
discoverMore: 'Oppdag flere',
|
||||
discoverArtists: 'Oppdag artister',
|
||||
discoverArtistsMore: 'Alle artister'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Utvalgt album',
|
||||
playAlbum: 'Spill av album',
|
||||
enqueue: 'Legg til i kø',
|
||||
enqueueTooltip: 'Legg til hele albumet i køen',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Søk etter artist, album eller sang…',
|
||||
noResults: 'Ingen resultater funnet for "{{query}}"',
|
||||
artists: 'Artister',
|
||||
albums: 'Album',
|
||||
songs: 'Sanger',
|
||||
clearLabel: 'Fjern søk',
|
||||
title: 'Søk',
|
||||
resultsFor: 'Resultater for "{{query}}"',
|
||||
album: 'Album',
|
||||
advanced: 'Avansert søk',
|
||||
advancedSearchTerm: 'Søkeord',
|
||||
advancedSearchPlaceholder: 'Tittel, album, artist…',
|
||||
advancedGenre: 'Sjanger',
|
||||
advancedAllGenres: 'Alle sjangre',
|
||||
advancedYear: 'År',
|
||||
advancedYearFrom: 'fra',
|
||||
advancedYearTo: 'til',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Søk',
|
||||
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
|
||||
advancedNoResults: 'Ingen resultater funnet.',
|
||||
advancedGenreNote: 'Sanger er tilfeldig valgt fra denne sjangeren.',
|
||||
recentSearches: 'Siste søk',
|
||||
browse: 'Utforsk',
|
||||
emptyHint: 'Hva vil du høre?',
|
||||
genres: 'Sjangre',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Hvem lytter?',
|
||||
title: 'Hvem lytter?',
|
||||
loading: 'Laster…',
|
||||
nobody: 'Ingen lyttere for øyeblikket.',
|
||||
minutesAgo: '{{n}}m siden',
|
||||
nothingPlaying: 'Ingenting spiller akkurat å. Start et spor!',
|
||||
aboutArtist: 'Om artisten',
|
||||
fromAlbum: 'Fra dette albumet',
|
||||
viewAlbum: 'Se album',
|
||||
goToArtist: 'Gå til artist',
|
||||
readMore: 'Les mer',
|
||||
showLess: 'Vis mindre',
|
||||
genreInfo: 'Sjanger',
|
||||
trackInfo: 'Sporinfo',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Spill nå',
|
||||
playNext: 'Spill neste',
|
||||
addToQueue: 'Legg til i kø',
|
||||
enqueueAlbum: 'Legg albumet i kø',
|
||||
startRadio: 'Start radio',
|
||||
lfmLove: 'Lik på Last.fm',
|
||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||
favorite: 'Favoritt',
|
||||
favoriteArtist: 'Favorittartist',
|
||||
favoriteAlbum: 'Favorittalbum',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
unfavoriteArtist: 'Fjern artist fra favoritter',
|
||||
unfavoriteAlbum: 'Fjern album fra favoritter',
|
||||
removeFromQueue: 'Fjern fra kø',
|
||||
openAlbum: 'Åpne album',
|
||||
goToArtist: 'Gå til artist',
|
||||
download: 'Last ned (ZIP)',
|
||||
addToPlaylist: 'Legg til i spilleliste',
|
||||
songInfo: 'Sanginfo',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Tilbake',
|
||||
playAll: 'Spill alt',
|
||||
enqueue: 'Legg i kø',
|
||||
enqueueTooltip: 'Legg hele albumet i kø',
|
||||
artistBio: 'Artistbiografi',
|
||||
download: 'Last ned (ZIP)',
|
||||
downloading: 'Laster…',
|
||||
cacheOffline: 'Gjør tilgjengelig som frakoblet',
|
||||
offlineCached: 'Tilgjengelig frakoblet',
|
||||
offlineDownloading: 'Bufrer… ({{n}}/{{total}})',
|
||||
removeOffline: 'Fjern frakoblet hurtigbuffer',
|
||||
offlineStorageFull: 'Frakoblet lagring er full (lagringsgrense: {{mb}} MB). Frigjør plass, ved å slette et album fra ditt frakoblede bibliotek, eller øk lagringsgrensen i Innstillinger.',
|
||||
offlineStorageGoToLibrary: 'Frakoblet bibliotek',
|
||||
offlineStorageGoToSettings: 'Innstillinger',
|
||||
favoriteAdd: 'Legg til i favoritter',
|
||||
favoriteRemove: 'Fjern fra favoritter',
|
||||
favorite: 'Favoritt',
|
||||
noBio: 'Ingen biografi er tilgjengelig.',
|
||||
moreByArtist: 'Mer av {{artist}}',
|
||||
tracksCount: '{{n}} spor',
|
||||
goToArtist: 'Gå til {{artist}}',
|
||||
moreLabelAlbums: 'Flere album på {{label}}',
|
||||
trackTitle: 'Tittel',
|
||||
trackArtist: 'Artist',
|
||||
trackGenre: 'Sjanger',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favoritt',
|
||||
trackRating: 'Vurdering',
|
||||
trackDuration: 'Varighet',
|
||||
trackTotal: 'Totalt',
|
||||
columns: 'Kolonner',
|
||||
notFound: 'Album ble ikke funnet.',
|
||||
bioModal: 'Artistbiografi',
|
||||
bioClose: 'Lukk',
|
||||
ratingLabel: 'Vurdering',
|
||||
enlargeCover: 'Forstørr',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Tilbake',
|
||||
albums: 'Album',
|
||||
album: 'Album',
|
||||
playAll: 'Spill alt',
|
||||
shuffle: 'Tilfeldig rekkefølge',
|
||||
radio: 'Radio',
|
||||
loading: 'Laster…',
|
||||
noRadio: 'Ingen lignende spor funnet for denne artisten.',
|
||||
notFound: 'Artisten ble ikke funnet.',
|
||||
albumsBy: 'Album fra {{name}}',
|
||||
topTracks: 'Toppspor',
|
||||
noAlbums: 'Ingen album funnet.',
|
||||
trackTitle: 'Tittel',
|
||||
trackAlbum: 'Album',
|
||||
trackDuration: 'Varighet',
|
||||
favoriteAdd: 'Legg til i favoritter',
|
||||
favoriteRemove: 'Fjern fra favoritter',
|
||||
favorite: 'Favoritt',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} album',
|
||||
openedInBrowser: 'Åpnet i nettleser',
|
||||
featuredOn: 'Også en del av',
|
||||
similarArtists: 'Lignende artister',
|
||||
cacheOffline: 'Lagre diskografi som frakoblet',
|
||||
offlineCached: 'Diskografi bufret',
|
||||
offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)',
|
||||
uploadImage: 'Last opp artistbilde',
|
||||
uploadImageError: 'Kunne ikke laste opp bildet',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoritter',
|
||||
empty: "Du har ikke lagret noen favoritter ennå.",
|
||||
artists: 'Artister',
|
||||
albums: 'Album',
|
||||
songs: 'Sanger',
|
||||
enqueueAll: 'Legg alle i kø',
|
||||
playAll: 'Spill alle',
|
||||
removeSong: 'Fjern fra favoritter',
|
||||
stations: 'Radiostasjoner',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Tilfeldige album',
|
||||
refresh: 'Oppdater',
|
||||
},
|
||||
genres: {
|
||||
title: 'Sjangre',
|
||||
genreCount: 'Sjangre',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} album',
|
||||
loading: 'Laster sjangre…',
|
||||
empty: 'Ingen sjangre funnet.',
|
||||
albumsLoading: 'Laster album…',
|
||||
albumsEmpty: 'Ingen album funnet for denne sjangeren.',
|
||||
loadMore: 'Last mer',
|
||||
back: 'Tilbake',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Tilfeldig miks',
|
||||
remix: 'Remiks',
|
||||
remixTooltip: 'Last inn nye tilfeldige sanger',
|
||||
playAll: 'Spill alt',
|
||||
trackTitle: 'Tittel',
|
||||
trackArtist: 'Artist',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favoritt',
|
||||
trackDuration: 'Varighet',
|
||||
favoriteAdd: 'Legg til i favoritter',
|
||||
favoriteRemove: 'Fjern fra favoritter',
|
||||
play: 'Spill',
|
||||
trackGenre: 'Sjanger',
|
||||
excludeAudiobooks: 'Ekskluder lydbøker og hørespill',
|
||||
excludeAudiobooksDesc: 'Samsvarer nøkkelord mot sjanger, tittel, album og artist - f.eks. Hørespill, Lydbok, Spoken Word, …',
|
||||
genreBlocked: 'Nøkkelord blokkert',
|
||||
genreAddedToBlacklist: 'Lagt til i filterliste',
|
||||
genreAlreadyBlocked: 'Allerede blokkert',
|
||||
artistBlocked: 'Artisten er blokkert',
|
||||
artistAddedToBlacklist: 'Artist er lagt til i filterliste',
|
||||
artistClickHint: 'Klikk for å blokkere denne artisten',
|
||||
blacklistToggle: 'Nøkkelordfilter',
|
||||
genreMixTitle: 'Sjangermiks',
|
||||
genreMixDesc: 'Topp 20 sjangre etter antall sanger - klikk for å laste en tilfeldig miks',
|
||||
genreMixLoadMore: 'Last 10 til',
|
||||
genreMixNoGenres: 'Ingen sjangre funnet på tjeneren.',
|
||||
shuffleGenres: 'Vis andre sjangre',
|
||||
filterPanelTitle: 'Filtre',
|
||||
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.',
|
||||
genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle album',
|
||||
sortByName: 'A–Å (Album)',
|
||||
sortByArtist: 'A–Å (Artist)',
|
||||
sortNewest: 'Vis nyeste først',
|
||||
sortRandom: 'Tilfeldig',
|
||||
yearFrom: 'Fra',
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artister',
|
||||
search: 'Søk…',
|
||||
all: 'Alle',
|
||||
gridView: 'Rutenettvisning',
|
||||
listView: 'Listevisning',
|
||||
imagesOn: 'Artistbilder på - kan øke belastningen på nettverket og applikasjonen',
|
||||
imagesOff: 'Artistbilder av - viser kun initialer',
|
||||
loadMore: 'Last flere',
|
||||
notFound: 'Ingen artister funnet.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} album',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Din Navidrome-mediaspiller',
|
||||
serverName: 'Tjenernavn (valgfritt)',
|
||||
serverNamePlaceholder: 'Mitt mediabibliotek',
|
||||
serverUrl: 'Tjener-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 eller feks. musikk.eksempel.com',
|
||||
username: 'Brukernavn',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Passord',
|
||||
showPassword: 'Vis passord',
|
||||
hidePassword: 'Skjul passord',
|
||||
connect: 'Koble til',
|
||||
connecting: 'Kobler til…',
|
||||
connected: 'Tilkoblet!',
|
||||
error: 'Tilkoblingen mislyktes – vennligst sjekk instillingene.',
|
||||
urlRequired: 'Vennligst skriv inn en tjeneer-URL.',
|
||||
savedServers: 'Lagrede servere',
|
||||
addNew: 'Eller legg til en ny tjener',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Tilkoblet',
|
||||
connectedTo: 'Tilkoblet til {{server}}',
|
||||
disconnected: 'Frakoblet',
|
||||
disconnectedFrom: 'Kan ikke nå {{server}} - klikk for å sjekke innstillinger',
|
||||
checking: 'Kobler til…',
|
||||
extern: 'Ekstern',
|
||||
offlineTitle: 'Ingen tjenertilkobling',
|
||||
offlineSubtitle: 'Kan ikke nå {{server}}. Sjekk nettverket eller tjeneren din.',
|
||||
offlineModeBanner: 'Frakoblet modus - spiller fra lokal hurtigbuffer',
|
||||
offlineLibraryTitle: 'Frakoblet bibliotek',
|
||||
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} album',
|
||||
offlineFilterAll: 'Alle',
|
||||
offlineFilterAlbums: 'Album',
|
||||
offlineFilterPlaylists: 'Spillelister',
|
||||
offlineFilterArtists: 'Diskografier',
|
||||
retry: 'Prøv igjen',
|
||||
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
||||
},
|
||||
common: {
|
||||
albums: 'Album',
|
||||
album: 'Album',
|
||||
loading: 'Laster…',
|
||||
loadingMore: 'Laster…',
|
||||
loadingPlaylists: 'Laster spillelister…',
|
||||
noAlbums: 'Ingen album funnet.',
|
||||
downloading: 'Laster ned…',
|
||||
downloadZip: 'Last ned (ZIP)',
|
||||
back: 'Tilbake',
|
||||
cancel: 'Avbryt',
|
||||
save: 'Lagre',
|
||||
delete: 'Slett',
|
||||
use: 'Bruk',
|
||||
add: 'Legg til',
|
||||
active: 'Aktiv',
|
||||
download: 'Last ned',
|
||||
chooseDownloadFolder: 'Velg nedlastingsmappe',
|
||||
noFolderSelected: 'Ingen mappe valgt',
|
||||
rememberDownloadFolder: 'Husk denne mappen',
|
||||
filterGenre: 'Sjangerfilter',
|
||||
filterSearchGenres: 'Søk i sjangre…',
|
||||
filterNoGenres: 'Ingen sjangre samsvarer',
|
||||
filterClear: 'Tøm',
|
||||
bulkSelected: '{{count}} valgt',
|
||||
bulkAddToPlaylist: 'Legg til i spilleliste',
|
||||
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
|
||||
bulkClear: 'Tøm utvalg',
|
||||
updaterAvailable: 'Ny versjon er tilgjengelig',
|
||||
updaterVersion: 'v{{version}} er tilgjengelig',
|
||||
updaterWebsite: 'Nettsted',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
language: 'Språk',
|
||||
languageEn: 'Engelsk',
|
||||
languageDe: 'Tysk',
|
||||
languageFr: 'Fransk',
|
||||
languageNl: 'Nederlandsk',
|
||||
languageZh: 'Kinesisk',
|
||||
languageNb: 'Norsk',
|
||||
languageRu: 'Russisk',
|
||||
font: 'Skrifttype',
|
||||
theme: 'Tema',
|
||||
appearance: 'Utseende',
|
||||
servers: 'Tjenere',
|
||||
serverName: 'Tjenernavn',
|
||||
serverUrl: 'Tjener-URL',
|
||||
serverUsername: 'Brukernavn',
|
||||
serverPassword: 'Passord',
|
||||
addServer: 'Legg til tjener',
|
||||
addServerTitle: 'Legg til ny tjener',
|
||||
useServer: 'Bruk',
|
||||
deleteServer: 'Slett',
|
||||
noServers: 'Ingen tjenere lagret.',
|
||||
serverActive: 'Aktiv',
|
||||
confirmDeleteServer: 'Slett tjener "{{name}}"?',
|
||||
serverConnecting: 'Kobler til…',
|
||||
serverConnected: 'Tilkoblet!',
|
||||
serverFailed: 'Tilkobling mislyktes.',
|
||||
testBtn: 'Test tilkobling',
|
||||
testingBtn: 'Tester…',
|
||||
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Tilkoblet',
|
||||
failed: 'Mislyktes',
|
||||
eqTitle: 'Jevnstiller',
|
||||
eqEnabled: 'Aktiver jevnstiller',
|
||||
eqPreset: 'Forhåndsinnstilling',
|
||||
eqPresetCustom: 'Egendefinert',
|
||||
eqPresetBuiltin: 'Innebygde forhåndsinnstillinger',
|
||||
eqPresetCustomGroup: 'Mine forhåndsinnstillinger',
|
||||
eqSavePreset: 'Lagre som forhåndsinnstilling',
|
||||
eqPresetName: 'Navn på forhåndsinnstilling…',
|
||||
eqDeletePreset: 'Slett forhåndsinnstilling',
|
||||
eqResetBands: 'Tilbakestill til standard',
|
||||
eqPreGain: 'Forforsterkning',
|
||||
eqResetPreGain: 'Tilbakestill forforsterkning',
|
||||
eqAutoEqTitle: 'AutoEQ hodetelefonoppslag',
|
||||
eqAutoEqPlaceholder: 'Søk etter hodetelefon- / IEM-modell…',
|
||||
eqAutoEqSearching: 'Søker…',
|
||||
eqAutoEqNoResults: 'Ingen resultater funnet',
|
||||
eqAutoEqError: 'Søk mislyktes',
|
||||
eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
|
||||
eqAutoEqFetchError: 'Kunne ikke hente EQ-profil',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Koble til med Last.fm',
|
||||
lfmConnecting: 'Venter på autorisasjon…',
|
||||
lfmConfirm: 'Jeg har autorisert appen',
|
||||
lfmConnected: 'Tilkoblet som',
|
||||
lfmDisconnect: 'Koble fra',
|
||||
lfmConnectDesc: 'Koble til din Last.fm-konto for å aktivere scrobbling og få "Nå spiller"-oppdateringer direkte fra Psysonic - ingen Navidrome-konfigurasjon kreves.',
|
||||
lfmOpenBrowser: 'Et nettleservindu vil åpne seg. Autoriser Psysonic via Last.fm, og klikk deretter på knappen nedenfor.',
|
||||
lfmScrobbles: '{{n}} scrobbles',
|
||||
lfmMemberSince: 'Medlem siden {{year}}',
|
||||
scrobbleEnabled: 'Scrobbling er aktivert',
|
||||
scrobbleDesc: 'Send sanger til Last.fm etter 50 % avspilling',
|
||||
behavior: 'App-oppførsel',
|
||||
cacheTitle: 'Maks. lagringsstørrelse',
|
||||
cacheDesc: 'Plateomslag og artistbilder. Når den er full, fjernes de eldste oppføringene automatisk. Frakoblede album fjernes ikke automatisk, men slettes når hurtigbufferen tømmes manuelt.',
|
||||
cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor',
|
||||
cacheUsedImages: 'Bilder:',
|
||||
cacheUsedOffline: 'Frakoblede spor:',
|
||||
cacheUsedHot: 'Plass brukt:',
|
||||
hotCacheTrackCount: 'Spor i buffer:',
|
||||
cacheMaxLabel: 'Maks størrelse',
|
||||
cacheClearBtn: 'Tøm hurtigbuffer',
|
||||
cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.',
|
||||
cacheClearConfirm: 'Tøm alt',
|
||||
cacheClearCancel: 'Avbryt',
|
||||
offlineDirTitle: 'Frakoblet bibliotek (In-App)',
|
||||
offlineDirDesc: 'Lagringsplassering for spor som du gjør tilgjengelige som frakoblet i Psysonic.',
|
||||
offlineDirDefault: 'Standard (App-data)',
|
||||
offlineDirChange: 'Endre katalog',
|
||||
offlineDirClear: 'Tilbakestill til standard',
|
||||
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
|
||||
hotCacheTitle: 'Varm avspillingsbuffer',
|
||||
hotCacheAlphaBadge: 'Alfa',
|
||||
hotCacheDisclaimer: 'Forhåndshenter neste spor i køen og beholder tidligere. Når aktivert: diskplass og nettverk.',
|
||||
hotCacheDirDefault: 'Standard (App-data)',
|
||||
hotCacheDirChange: 'Endre mappe',
|
||||
hotCacheDirClear: 'Tilbakestill til standard',
|
||||
hotCacheDirHint: 'Bytte mappe nullstiller indeksen i appen; gamle filer blir liggende til du sletter dem.',
|
||||
hotCacheEnabled: 'Aktiver varm avspillingsbuffer',
|
||||
hotCacheMaxMb: 'Maks bufferstørrelse',
|
||||
hotCacheDebounce: 'Utsettelse ved køendring',
|
||||
hotCacheDebounceImmediate: 'Umiddelbart',
|
||||
hotCacheDebounceSeconds: '{{n}} sek',
|
||||
hotCacheClearBtn: 'Tøm varm buffer',
|
||||
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
|
||||
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
|
||||
showArtistImages: 'Vis artistbilder',
|
||||
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.',
|
||||
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',
|
||||
discordAppleCoversDesc: 'Sender artist- og albumnavn til Apples søke-API for å finne cover til Discord-profilen din. Deaktivert som standard av personvernhensyn.',
|
||||
nowPlayingEnabled: 'Vis i "Nå spiller"',
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
|
||||
downloadsTitle: 'ZIP Eksport & Arkivering',
|
||||
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
|
||||
downloadsDefault: 'Standard nedlastingsmappe',
|
||||
pickFolder: 'Velg',
|
||||
pickFolderTitle: 'Velg nedlastingsmappe',
|
||||
clearFolder: 'Tøm nedlastingsmappe',
|
||||
logout: 'Logg ut',
|
||||
aboutTitle: 'Om Psysonic',
|
||||
aboutDesc: 'En moderne musikkspiller for Subsonic-kompatible tjenere (Navidrome, Gonic og andre). Bygget på Tauri v2 med en innebygd Rust-lydmotor - lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
|
||||
aboutFeatures: 'Multi-tjener · Last.fm scrobbling og liker · Fullskjerm Ambient Stage · Bølgeformsøk · Synkroniserte tekster · 10-bånds jevnstiller · Crossfade og gapless · Replay Gain · Sjangre · 63 temaer · 6 språk',
|
||||
aboutLicense: 'Lisens',
|
||||
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
|
||||
aboutRepo: 'Kildekode på GitHub',
|
||||
aboutVersion: 'Versjon',
|
||||
aboutBuiltWith: 'Bygget med Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Utviklet med støtte fra Claude Code laget av Anthropic',
|
||||
aboutContributorsLabel: 'Bidragsytere',
|
||||
aboutSpecialThanksLabel: 'Spesiell takk',
|
||||
changelog: 'Endringslogg',
|
||||
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
|
||||
showChangelogOnUpdateDesc: "Vis automatisk hva som er nytt når en ny versjon lanseres for første gang.",
|
||||
randomMixTitle: 'Tilfeldig miks',
|
||||
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
|
||||
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
|
||||
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
|
||||
randomMixBlacklistAdd: 'Legg til',
|
||||
randomMixBlacklistEmpty: 'Ingen egendefinerte nøkkelord lagt til ennå.',
|
||||
randomMixHardcodedTitle: 'Innebygde nøkkelord (aktiv når avkrysningsboksen er på)',
|
||||
tabPlayback: 'Avspilling',
|
||||
tabLibrary: 'Bibliotek',
|
||||
tabAppearance: 'Utseende',
|
||||
homeCustomizerTitle: 'Hjemmeside',
|
||||
sidebarTitle: 'Sidefelt',
|
||||
sidebarReset: 'Tilbakestill til standard',
|
||||
sidebarDrag: 'Dra for å endre rekkefølge',
|
||||
sidebarFixed: 'Alltid synlig',
|
||||
tabShortcuts: 'Snarveier',
|
||||
tabServer: 'Tjener',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Generelt',
|
||||
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
||||
backupExport: 'Eksporter innstillinger',
|
||||
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
||||
backupImport: 'Importer innstillinger',
|
||||
backupImportDesc: 'Gjenoppretter innstillinger fra en .psybkp-fil. Applikasjonen starter på nytt etter import.',
|
||||
backupImportConfirm: 'Dette vil overskrive alle gjeldende innstillinger. Vil du fortsette?',
|
||||
backupSuccess: 'Sikkerhetskopiering lagret',
|
||||
backupImportSuccess: 'Innstillinger gjenopprettet – laster inn data på nytt…',
|
||||
backupImportError: 'Ugyldig eller ødelagt fil for sikkerhetskopi.',
|
||||
shortcutsReset: 'Tilbakestill til standardinnstillinger',
|
||||
shortcutListening: 'Trykk på en tast…',
|
||||
shortcutUnbound: '-',
|
||||
globalShortcutsTitle: 'Globale snarveier',
|
||||
globalShortcutsNote: 'Arbeid systemomfattende selv når Psysonic er i bakgrunnen. Krever Ctrl, Alt eller Super som modifikator.',
|
||||
shortcutClear: 'Fjern',
|
||||
shortcutPlayPause: 'Spill av / Pause',
|
||||
shortcutNext: 'Neste spor',
|
||||
shortcutPrev: 'Forrige spor',
|
||||
shortcutVolumeUp: 'Volum opp',
|
||||
shortcutVolumeDown: 'Volum ned',
|
||||
shortcutSeekForward: 'Søk fremover 10 sekunder',
|
||||
shortcutSeekBackward: 'Søk bakover 10 sekunder',
|
||||
shortcutToggleQueue: 'Veksle kø',
|
||||
shortcutFullscreenPlayer: 'Fullskjermsspiller',
|
||||
shortcutNativeFullscreen: 'Naturlig fullskjerm',
|
||||
playbackTitle: 'Avspilling',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata',
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Spor',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Tone mellom spor',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
notWithGapless: 'Ikke tilgjengelig mens Gapless er aktiv',
|
||||
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
|
||||
gapless: 'Gapless avspilling',
|
||||
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
|
||||
infiniteQueue: 'Uendelig kø',
|
||||
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
|
||||
experimental: 'Eksperimentell',
|
||||
preloadMode: 'Forhåndslast neste spor',
|
||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
||||
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
dontShowAgain: "Ikke vis igjen",
|
||||
close: 'Forstått',
|
||||
},
|
||||
help: {
|
||||
title: 'Hjelp',
|
||||
s1: 'Komme i gang',
|
||||
q1: 'Hvilke servere er kompatible?',
|
||||
a1: 'Psysonic fungerer med alle Subsonic-kompatible servere: Navidrome, Gonic, Subsonic, Airsonic og andre. Navidrome er det anbefalte valget.',
|
||||
q2: 'Hvordan kobler jeg til serveren min?',
|
||||
a2: 'Åpne Innstillinger og klikk på "Legg til server". Skriv inn server-URL-en (f.eks. 192.168.1.100:4533), brukernavn og passord. Psysonic tester tilkoblingen før den lagrer – ingenting lagres hvis tilkoblingen mislykkes.',
|
||||
q3: 'Kan jeg bruke flere servere?',
|
||||
a3: 'Ja. Du kan legge til så mange servere du vil i Innstillinger og bytte mellom dem når som helst. Bare én server er aktiv om gangen.',
|
||||
s2: 'Avspilling',
|
||||
q4: 'Hvordan spiller jeg musikk?',
|
||||
a4: 'Dobbeltklikk på et spor for å spille det av. På album- og artistsider bruker du "Spill av alle" for å starte hele albumet. Du kan også dra spor inn i køpanelet.',
|
||||
q5: 'Hvilke hurtigtaster er tilgjengelige?',
|
||||
a5: 'Mellomrom = Spill av / Pause · Escape = Lukk fullskjermsspiller. Medietaster (Spill av / Pause, Neste, Forrige) fungerer på alle plattformer. På Linux bruker du knappene i spillerlinjen for medietaster. Snarveier i appen og systemomfattende globale snarveier kan tilpasses i Innstillinger → Tastatursnarveier / Globale snarveier.',
|
||||
q6: 'Hva er køen?',
|
||||
a6: 'Køen viser alle kommende spor. Åpne den med panelikonet øverst til høyre (ved siden av Spilles nå-indikatoren). Du kan endre rekkefølgen på sporene ved å dra, spille av i tilfeldig rekkefølge med tilfeldig-knappen og lagre køen som en spilleliste.',
|
||||
q7: 'Hvordan åpner jeg fullskjermsspilleren?',
|
||||
a7: 'Klikk på miniatyrbildet av albumcoveret i spillerlinjen nederst, eller på utvid-ikonet ved siden av. Trykk på Escape for å lukke det igjen.',
|
||||
q8: 'Hvordan fungerer gjentakelse?',
|
||||
a8: 'Klikk på gjentakelsesknappen i spillerlinjen for å bla gjennom: Av → Gjenta alle → Gjenta én.',
|
||||
s3: 'Bibliotek',
|
||||
q9: 'Hvordan laster jeg ned et album?',
|
||||
a9: 'Åpne detaljsiden for et album og klikk på "Last ned (ZIP)". Serveren komprimerer albumet til en ZIP-fil først – for store album eller filer uten tap (FLAC/WAV) kan dette ta et øyeblikk før nedlastingen faktisk starter. Dette er normalt: fremdriftslinjen vises når serveren er ferdig med å zippe og overføringen starter.',
|
||||
q10: 'Hvordan stjernemerker/favoritterer jeg spor og album?',
|
||||
a10: 'Klikk på stjerneikonet på en hvilken som helst sporrad eller i albumoverskriften. Stjernemerkede elementer vises i Favoritter-delen i sidefeltet.',
|
||||
q11: 'Hva er hovedkarusellen på hjemmesiden?',
|
||||
a11: 'Banneret øverst på hjemmesiden velger tilfeldig album fra biblioteket ditt og roterer gjennom dem hvert 10. sekund. Klikk på prikkene for å hoppe til et bestemt album, eller klikk på banneret for å åpne albumet.',
|
||||
s4: 'Innstillinger',
|
||||
q12: 'Hvordan endrer jeg temaet?',
|
||||
a12: 'Innstillinger → Tema. Velg fra et stort utvalg av temaer på tvers av 8 grupper: Psysonic-temaer, mediaspiller, operativsystemer, spill, filmer, serier, sosiale medier og åpen kildekode-klassikere (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'Hvordan endrer jeg språket?',
|
||||
a13: 'Innstillinger → Språk. Engelsk, tysk, fransk, nederlandsk og kinesisk støttes.',
|
||||
q15: 'Hvordan angir jeg en nedlastingsmappe?',
|
||||
a15: 'Innstillinger → App-oppførsel → Nedlastingsmappe. Velg en hvilken som helst mappe – nedlastede album lagres der som ZIP-filer. Uten en tilpasset mappe brukes nettleserens standard nedlastingsplassering.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'Hvordan fungerer scrobbling?',
|
||||
a16: 'Psysonic scrobbler direkte til Last.fm - ingen Navidrome-konfigurasjon nødvendig. Koble til Last.fm-kontoen din i Innstillinger → Last.fm og aktiver scrobbling der.',
|
||||
q17: 'Når sendes en scrobble?',
|
||||
a17: 'En scrobble sendes etter at du har lyttet til 50 % av et spor.',
|
||||
q22: 'Hva er bølgeformen i avspillerlinjen?',
|
||||
a22: 'Bølgeformsøkelinjen erstatter den klassiske fremdriftsglidebryteren. Klikk hvor som helst på den for å hoppe til den posisjonen, eller dra for å skrubbe. Den avspilte delen lyser med en blå-til-lilla gradient, det bufrede området vises litt lysere, og den uavspilte delen er falmet.',
|
||||
q24: 'Kan jeg blande køen?',
|
||||
a24: 'Ja. Åpne køpanelet og klikk på blandeikonet i køoverskriften. Sporet som spilles av for øyeblikket forblir på posisjon 1 – alle gjenværende spor blir tilfeldig omorganisert.',
|
||||
q25: 'Åpnes Last.fm- og Wikipedia-lenker på artistsider i en nettleser?',
|
||||
a25: 'Ja – de åpnes i standardnettleseren din. Knappen viser kort en bekreftelsesetikett når du klikker på den.',
|
||||
s7: 'Tilfeldig miks',
|
||||
q26: 'Hva er tilfeldig miks?',
|
||||
a26: 'Tilfeldig miks bygger en spilleliste med tilfeldige spor fra hele biblioteket ditt. Åpne den via "Tilfeldig miks" i sidefeltet og klikk på "Ny miks". Du kan justere antall spor før generering.',
|
||||
q27: 'Hva er nøkkelordfilteret?',
|
||||
a27: 'Nøkkelordfilteret ekskluderer spor der sjanger, tittel eller albumnavn inneholder spesifikke ord. Lydbøker filtreres automatisk. Legg til egendefinerte nøkkelord i Innstillinger → Tilfeldig miks, eller klikk på en hvilken som helst sjangertagg i sporlisten for å legge den til umiddelbart.',
|
||||
q28: 'Hva er Super Genre Mix?',
|
||||
a28: 'Super Genre Mix grupperer biblioteket ditt i brede kategorier (rock, metal, elektronisk, jazz, klassisk osv.) og bygger en fokusert miks fra den stilen. Velg en kategoribrikke under sporlisten. Resultatene vises gradvis etter hvert som hver undersjanger hentes.',
|
||||
s6: 'Feilsøking',
|
||||
q18: 'Omslagsbilder og artistbilder lastes sakte.',
|
||||
a18: 'Bilder hentes fra serverens disk ved første innlasting og bufres deretter lokalt i 30 dager. Hvis serverens lagring er treg, kan det ta et øyeblikk før det første besøket på en side. Senere besøk vil være umiddelbare.',
|
||||
q19: 'Tilkoblingstesten mislykkes.',
|
||||
a19: 'Sjekk URL-en inkludert porten (f.eks. http://192.168.1.100:4533). Sørg for at ingen brannmur blokkerer tilkoblingen. Prøv http:// i stedet for https:// på et lokalt nettverk. Bekreft også at brukernavnet og passordet ditt er riktig.',
|
||||
q20: 'Ingen lyd på Linux.',
|
||||
a20: 'Psysonic er tilgjengelig som .deb (Ubuntu/Debian), .rpm (Fedora/RHEL), og via AUR på Arch/CachyOS (yay -S psysonic). Det finnes ingen AppImage. Hvis lyd mangler, sørg for at PipeWire eller PulseAudio kjører.',
|
||||
q21: 'Appen viser en svart skjerm på Linux.',
|
||||
a21: 'Dette er vanligvis et GPU/EGL-driverproblem i WebKitGTK. Start med GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. AUR-pakken og offisielle .deb/.rpm-installasjonsprogrammer setter disse automatisk.',
|
||||
q29: 'Hva er Crossfade og Gapless Playback?',
|
||||
a29: 'Begge er eksperimentelle lydfunksjoner i Innstillinger → Lyd. Gapless forhåndsbuffer neste spor for å eliminere stillhet mellom sanger. Crossfade fader ut gjeldende spor mens det fader inn det neste – juster varigheten (1–10 s) etter smak.',
|
||||
q30: 'Viser Psysonic sangtekst?',
|
||||
a30: 'Ja. Klikk på mikrofonikonet i spillerlinjen, for å åpne Sangtekst-fanen i køpanelet. Psysonic henter tekst automatisk fra LRCLIB. Hvis synkroniserte tekster er tilgjengelige, utheves den aktive linjen og ruller i sanntid mens sangen spilles av. Hvis bare ren tekst er tilgjengelig, vises fullteksten statisk.',
|
||||
q31: 'Kan jeg tilpasse hurtigtaster?',
|
||||
a31: 'Ja. Innstillinger → Tastatursnarveier lar deg tilordne handlinger i appen (Spill av/pause, Neste, Forrige, Volum opp/ned, Fullskjerm og mer) til en hvilken som helst tast. Innstillinger → Globale snarveier lar deg tilordne systemomfattende snarveier som utløses selv når Psysonic er i bakgrunnen.',
|
||||
q32: 'Kan jeg endre skrifttypen?',
|
||||
a32: 'Ja. Innstillinger → Skrifttype lar deg velge mellom 10 skrifttyper, inkludert IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime og andre. Den valgte skrifttypen gjelder for hele brukergrensesnittet.',
|
||||
s8: 'Frakoblet modus',
|
||||
q34: 'Hva er frakoblet modus?',
|
||||
a34: 'Frakoblet modus (beta) lar deg mellomlagre album til enheten din, slik at du kan lytte uten en aktiv servertilkobling. Bufrede spor lagres lokalt og spilles av direkte fra harddisken – ingen nettverksforespørsel gjøres under avspilling.',
|
||||
q35: 'Hvordan bufrer jeg et album for å bruke det offline?',
|
||||
a35: 'Åpne et hvilket som helst album og klikk på nedlastingsikonet i albumoverskriften. Psysonic laster ned alle spor i bakgrunnen. Fremdrift vises på knappen. Når bufren er lagret, blir ikonet grønt. Du kan vise og fjerne bufrede album på siden Offline bibliotek (sidefelt).',
|
||||
q36: 'Hvor mye lagringsplass kan frakoblet mellomlagring bruke?',
|
||||
a36: 'Du kan angi en maksimal mellomlagringsstørrelse i Innstillinger → Bibliotek. Når grensen er nådd, vises et advarselsbanner på albumsiden. Du kan slette individuelle album fra frakoblet bibliotek for å frigjøre plass.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Kø',
|
||||
savePlaylist: 'Lagre spilleliste',
|
||||
updatePlaylist: 'Oppdater spilleliste',
|
||||
filterPlaylists: 'Filtrer spillelister…',
|
||||
playlistName: 'Spillelistenavn',
|
||||
cancel: 'Avbryt',
|
||||
save: 'Lagre',
|
||||
loadPlaylist: 'Last inn spilleliste',
|
||||
loading: 'Laster…',
|
||||
noPlaylists: 'Ingen spillelister funnet.',
|
||||
load: 'Erstatt kø og spill av',
|
||||
appendToQueue: 'Legg til i kø',
|
||||
delete: 'Slett',
|
||||
deleteConfirm: 'Slett spillelisten "{{name}}"?',
|
||||
clear: 'Fjern',
|
||||
shuffle: 'Bland kø',
|
||||
gapless: 'Uten mellomrom',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Uendelig kø',
|
||||
autoAdded: '- Lagt til automatisk -',
|
||||
radioAdded: '- Radio -',
|
||||
hide: 'Skjul',
|
||||
close: 'Lukk',
|
||||
nextTracks: 'Neste spor',
|
||||
emptyQueue: 'Køen er tom.',
|
||||
trackSingular: 'spor',
|
||||
trackPlural: 'spor',
|
||||
showRemaining: 'Vis gjenværende tid',
|
||||
showTotal: 'Vis total tid',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistikk',
|
||||
recentlyPlayed: 'Nylig spilt',
|
||||
mostPlayed: 'Mest spilte album',
|
||||
highestRated: 'Høyest rangerte album',
|
||||
genreDistribution: 'Sjangerfordeling (Topp 20)',
|
||||
loadMore: 'Last inn mer',
|
||||
statArtists: 'Artister',
|
||||
statAlbums: 'Album',
|
||||
statSongs: 'Sanger',
|
||||
statGenres: 'Sjangere',
|
||||
statPlaytime: 'Total spilletid',
|
||||
genreInsights: 'Sjangerinnsikt',
|
||||
formatDistribution: 'Formatdistribusjon',
|
||||
formatSample: 'Utvalg av {{n}} spor',
|
||||
computing: 'Utregner…',
|
||||
genreSongs: '{{count}} Sanger',
|
||||
genreAlbums: '{{count}} Album',
|
||||
recentlyAdded: 'Nylig lagt til',
|
||||
decadeDistribution: 'Album etter tiår',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Album',
|
||||
decadeUnknown: 'Ukjent',
|
||||
lfmTitle: 'Last.fm Statistikk',
|
||||
lfmTopArtists: 'Toppartister',
|
||||
lfmTopAlbums: 'Toppalbum',
|
||||
lfmTopTracks: 'Toppspor',
|
||||
lfmPlays: '{{count}} avspillinger',
|
||||
lfmPeriodOverall: 'Alltid',
|
||||
lfmPeriod7day: '7 dager',
|
||||
lfmPeriod1month: '1 måned',
|
||||
lfmPeriod3month: '3 måneder',
|
||||
lfmPeriod6month: '6 måneder',
|
||||
lfmPeriod12month: '12 måneder',
|
||||
lfmNotConnected: 'Koble til Last.fm i Innstillinger for å se statistikken din.',
|
||||
lfmRecentTracks: 'Siste Scrobble-innslag',
|
||||
lfmNowPlaying: 'Spilles nå',
|
||||
lfmJustNow: 'akkurat nå',
|
||||
lfmMinutesAgo: 'For {{n}}m siden',
|
||||
lfmHoursAgo: 'For {{n}}t siden',
|
||||
lfmDaysAgo: 'For {{n}}d siden',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikkspiller',
|
||||
openFullscreen: 'Åpne fullskjermsspiller',
|
||||
fullscreen: 'Fullskjermsspiller',
|
||||
closeFullscreen: 'Lukk fullskjerm',
|
||||
closeTooltip: 'Lukk (Esc)',
|
||||
noTitle: 'Ingen tittel',
|
||||
stop: 'Stopp',
|
||||
prev: 'Forrige spor',
|
||||
play: 'Spill av',
|
||||
pause: 'Pause',
|
||||
next: 'Neste spor',
|
||||
repeat: 'Gjenta',
|
||||
repeatOff: 'Av',
|
||||
repeatAll: 'Alle',
|
||||
repeatOne: 'Én',
|
||||
progress: 'Sangfremdrift',
|
||||
volume: 'Volum',
|
||||
toggleQueue: 'Veksle kø',
|
||||
lyrics: 'Sangtekst',
|
||||
fsLyricsToggle: 'Sangtekst i fullskjerm',
|
||||
lyricsLoading: 'Laster sangtekst…',
|
||||
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
|
||||
lyricsSourceServer: 'Kilde: Server',
|
||||
lyricsSourceLrclib: 'Kilde: LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Sanginfo',
|
||||
songTitle: 'Tittel',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumArtist: 'Albumartist',
|
||||
year: 'År',
|
||||
genre: 'Sjanger',
|
||||
duration: 'Varighet',
|
||||
track: 'Spor',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Samplingfrekvens',
|
||||
bitDepth: 'Bitdybde',
|
||||
channels: 'Kanaler',
|
||||
fileSize: 'Filstørrelse',
|
||||
path: 'Sti',
|
||||
replayGainTrack: 'RG-sporforsterkning',
|
||||
replayGainAlbum: 'RG-albumforsterkning',
|
||||
replayGainPeak: 'RG-sportopp',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Spillelister',
|
||||
newPlaylist: 'Ny spilleliste',
|
||||
unnamed: 'Navnløs spilleliste',
|
||||
createName: 'Spillelistenavn…',
|
||||
create: 'Opprett',
|
||||
cancel: 'Avbryt',
|
||||
empty: 'Ingen spillelister ennå.',
|
||||
emptyPlaylist: 'Denne spillelisten er tom.',
|
||||
addFirstSong: 'Legg til din første sang',
|
||||
notFound: 'Spillelisten ble ikke funnet.',
|
||||
songs: '{{n}} sanger',
|
||||
playAll: 'Spill alle',
|
||||
shuffle: 'Bland',
|
||||
addToQueue: 'Legg til i kø',
|
||||
back: 'Tilbake til spillelister',
|
||||
deletePlaylist: 'Slett',
|
||||
confirmDelete: 'Klikk igjen for å bekrefte',
|
||||
removeSong: 'Fjern fra spilleliste',
|
||||
addSongs: 'Legg til sanger',
|
||||
searchPlaceholder: 'Søk i biblioteket ditt…',
|
||||
noResults: 'Ingen resultater.',
|
||||
suggestions: 'Foreslåtte sanger',
|
||||
noSuggestions: 'Ingen forslag tilgjengelig.',
|
||||
titleBadge: 'Spilleliste',
|
||||
refreshSuggestions: 'Nye forslag',
|
||||
addSong: 'Legg til i spilleliste',
|
||||
cacheOffline: 'Bufre spilleliste offline',
|
||||
offlineCached: 'Spilleliste bufret',
|
||||
offlineDownloading: 'Bufre… ({{done}}/{{total}} album)',
|
||||
publicLabel: 'Offentlig',
|
||||
privateLabel: 'Privat',
|
||||
editMeta: 'Rediger spilleliste',
|
||||
editNamePlaceholder: 'Spillelistenavn…',
|
||||
editCommentPlaceholder: 'Legg til en beskrivelse…',
|
||||
editPublic: 'Offentlig spilleliste',
|
||||
editSave: 'Lagre',
|
||||
editCancel: 'Avbryt',
|
||||
changeCover: 'Endre omslagsbilde',
|
||||
changeCoverLabel: 'Endre bilde',
|
||||
removeCover: 'Fjern bilde',
|
||||
coverUpdated: 'Omslag oppdatert',
|
||||
metaSaved: 'Spillelisten er oppdatert',
|
||||
downloadZip: 'Last ned (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Mest spilt',
|
||||
topArtists: 'Toppkunstnere',
|
||||
topAlbums: 'Toppalbum',
|
||||
plays: '{{n}} avspillinger',
|
||||
sortMost: 'Mest spilt først',
|
||||
sortLeast: 'Minst spilt først',
|
||||
loadMore: 'Last inn flere album',
|
||||
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internettradio',
|
||||
empty: 'Ingen radiostasjoner konfigurert.',
|
||||
addStation: 'Legg til radio stasjon',
|
||||
editStation: 'Rediger',
|
||||
deleteStation: 'Slett stasjon',
|
||||
confirmDelete: 'Klikk igjen for å bekrefte',
|
||||
stationName: 'Stasjonsnavn…',
|
||||
streamUrl: 'Strøm-URL…',
|
||||
homepageUrl: 'Hjemmeside-URL (valgfritt)',
|
||||
save: 'Lagre',
|
||||
cancel: 'Avbryt',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internettradio',
|
||||
openHomepage: 'Åpne hjemmesiden',
|
||||
changeCoverLabel: 'Endre omslag',
|
||||
removeCover: 'Fjern omslag',
|
||||
browseDirectory: 'Søk i katalogen',
|
||||
directoryPlaceholder: 'Søk i stasjoner…',
|
||||
noResults: 'Ingen stasjoner funnet.',
|
||||
stationAdded: 'Stasjon lagt til',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favoritter',
|
||||
sortManual: 'Manuell',
|
||||
sortAZ: 'A → Å',
|
||||
sortZA: 'Å → A',
|
||||
sortNewest: 'Nyeste',
|
||||
favorite: 'Legg til i favoritter',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
noFavorites: 'Ingen favorittstasjoner.',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,824 @@
|
||||
export const nlTranslation = {
|
||||
sidebar: {
|
||||
library: 'Bibliotheek',
|
||||
mainstage: 'Hoofdpodium',
|
||||
newReleases: 'Nieuw',
|
||||
allAlbums: 'Alle albums',
|
||||
randomAlbums: 'Willekeurige albums',
|
||||
artists: 'Artiesten',
|
||||
randomMix: 'Willekeurige mix',
|
||||
favorites: 'Favorieten',
|
||||
nowPlaying: 'Nu bezig',
|
||||
system: 'Systeem',
|
||||
statistics: 'Statistieken',
|
||||
settings: 'Instellingen',
|
||||
help: 'Help',
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
starred: 'Persoonlijke favorieten',
|
||||
recent: 'Recent toegevoegd',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
recentlyPlayed: 'Recent afgespeeld',
|
||||
discover: 'Ontdekken',
|
||||
loadMore: 'Meer laden',
|
||||
discoverMore: 'Meer ontdekken',
|
||||
discoverArtists: 'Artiesten ontdekken',
|
||||
discoverArtistsMore: 'Alle artiesten'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Uitgelicht album',
|
||||
playAlbum: 'Album afspelen',
|
||||
enqueue: 'In wachtrij',
|
||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Zoek naar artiest, album of nummer…',
|
||||
noResults: 'Geen resultaten voor "{{query}}"',
|
||||
artists: 'Artiesten',
|
||||
albums: 'Albums',
|
||||
songs: 'Nummers',
|
||||
clearLabel: 'Zoekopdracht wissen',
|
||||
title: 'Zoeken',
|
||||
resultsFor: 'Resultaten voor "{{query}}"',
|
||||
album: 'Album',
|
||||
advanced: 'Geavanceerd zoeken',
|
||||
advancedSearchTerm: 'Zoekterm',
|
||||
advancedSearchPlaceholder: 'Titel, album, artiest…',
|
||||
advancedGenre: 'Genre',
|
||||
advancedAllGenres: 'Alle genres',
|
||||
advancedYear: 'Jaar',
|
||||
advancedYearFrom: 'van',
|
||||
advancedYearTo: 'tot',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Zoeken',
|
||||
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
|
||||
advancedNoResults: 'Geen resultaten gevonden.',
|
||||
advancedGenreNote: 'Nummers worden willekeurig uit dit genre geselecteerd.',
|
||||
recentSearches: 'Recente zoekopdrachten',
|
||||
browse: 'Bladeren',
|
||||
emptyHint: 'Wat wil je horen?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wie luistert er?',
|
||||
title: 'Wie luistert er?',
|
||||
loading: 'Laden…',
|
||||
nobody: 'Er luistert momenteel niemand.',
|
||||
minutesAgo: '{{n}} min geleden',
|
||||
nothingPlaying: 'Nog niets bezig. Start een nummer!',
|
||||
aboutArtist: 'Over de artiest',
|
||||
fromAlbum: 'Van dit album',
|
||||
viewAlbum: 'Album openen',
|
||||
goToArtist: 'Naar artiest',
|
||||
readMore: 'Meer lezen',
|
||||
showLess: 'Minder tonen',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Trackinfo',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Nu afspelen',
|
||||
playNext: 'Volgende afspelen',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
enqueueAlbum: 'Album in wachtrij',
|
||||
startRadio: 'Radio starten',
|
||||
lfmLove: 'Liken op Last.fm',
|
||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||
favorite: 'Favoriet',
|
||||
favoriteArtist: 'Favoriete artiest',
|
||||
favoriteAlbum: 'Favoriet album',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
unfavoriteArtist: 'Artiest uit favorieten verwijderen',
|
||||
unfavoriteAlbum: 'Album uit favorieten verwijderen',
|
||||
removeFromQueue: 'Uit wachtrij verwijderen',
|
||||
openAlbum: 'Album openen',
|
||||
goToArtist: 'Naar artiest',
|
||||
download: 'Downloaden (ZIP)',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
songInfo: 'Nummerinfo',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Terug',
|
||||
playAll: 'Alles afspelen',
|
||||
enqueue: 'In wachtrij',
|
||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||
artistBio: 'Artiest biografie',
|
||||
download: 'Downloaden (ZIP)',
|
||||
downloading: 'Laden…',
|
||||
cacheOffline: 'Offline beschikbaar maken',
|
||||
offlineCached: 'Offline beschikbaar',
|
||||
offlineDownloading: 'Wordt gecached… ({{n}}/{{total}})',
|
||||
removeOffline: 'Offline cache verwijderen',
|
||||
offlineStorageFull: 'Offline opslag vol (limiet: {{mb}} MB). Verwijder een album uit je offline bibliotheek of verhoog de limiet in de instellingen.',
|
||||
offlineStorageGoToSettings: 'Instellingen',
|
||||
offlineStorageGoToLibrary: 'Offline bibliotheek',
|
||||
favoriteAdd: 'Aan favorieten toevoegen',
|
||||
favoriteRemove: 'Uit favorieten verwijderen',
|
||||
favorite: 'Favoriet',
|
||||
noBio: 'Geen biografie beschikbaar.',
|
||||
moreByArtist: 'Meer van {{artist}}',
|
||||
tracksCount: '{{n}} nummers',
|
||||
goToArtist: 'Naar {{artist}}',
|
||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Artiest',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Formaat',
|
||||
trackFavorite: 'Favoriet',
|
||||
trackRating: 'Beoordeling',
|
||||
trackDuration: 'Duur',
|
||||
trackTotal: 'Totaal',
|
||||
columns: 'Kolommen',
|
||||
notFound: 'Album niet gevonden.',
|
||||
bioModal: 'Artiest biografie',
|
||||
bioClose: 'Sluiten',
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Alles afspelen',
|
||||
shuffle: 'Willekeurig',
|
||||
radio: 'Radio',
|
||||
loading: 'Laden…',
|
||||
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
|
||||
notFound: 'Artiest niet gevonden.',
|
||||
albumsBy: 'Albums van {{name}}',
|
||||
topTracks: 'Populaire nummers',
|
||||
noAlbums: 'Geen albums gevonden.',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackDuration: 'Duur',
|
||||
favoriteAdd: 'Aan favorieten toevoegen',
|
||||
favoriteRemove: 'Uit favorieten verwijderen',
|
||||
favorite: 'Favoriet',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
openedInBrowser: 'Geopend in browser',
|
||||
featuredOn: 'Ook te vinden op',
|
||||
similarArtists: 'Vergelijkbare artiesten',
|
||||
cacheOffline: 'Discografie offline opslaan',
|
||||
offlineCached: 'Discografie gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
uploadImage: 'Artiestafbeelding uploaden',
|
||||
uploadImageError: 'Uploaden van afbeelding mislukt',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorieten',
|
||||
empty: 'Je hebt nog geen favorieten opgeslagen.',
|
||||
artists: 'Artiesten',
|
||||
albums: 'Albums',
|
||||
songs: 'Nummers',
|
||||
enqueueAll: 'Alles aan wachtrij toevoegen',
|
||||
playAll: 'Alles afspelen',
|
||||
removeSong: 'Verwijderen uit favorieten',
|
||||
stations: 'Radiostations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Willekeurige albums',
|
||||
refresh: 'Vernieuwen',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
genreCount: 'genres',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
loading: 'Genres laden…',
|
||||
empty: 'Geen genres gevonden.',
|
||||
albumsLoading: 'Albums laden…',
|
||||
albumsEmpty: 'Geen albums gevonden voor dit genre.',
|
||||
loadMore: 'Meer laden',
|
||||
back: 'Terug',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Willekeurige mix',
|
||||
remix: 'Opnieuw mixen',
|
||||
remixTooltip: 'Nieuwe willekeurige nummers laden',
|
||||
playAll: 'Alles afspelen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Artiest',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favoriet',
|
||||
trackDuration: 'Duur',
|
||||
favoriteAdd: 'Aan favorieten toevoegen',
|
||||
favoriteRemove: 'Uit favorieten verwijderen',
|
||||
play: 'Afspelen',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten',
|
||||
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel, album en artiest — bijv. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Trefwoord geblokkeerd',
|
||||
genreAddedToBlacklist: 'Aan filterlijst toegevoegd',
|
||||
genreAlreadyBlocked: 'Al geblokkeerd',
|
||||
artistBlocked: 'Artiest geblokkeerd',
|
||||
artistAddedToBlacklist: 'Artiest aan filterlijst toegevoegd',
|
||||
artistClickHint: 'Klik om deze artiest te blokkeren',
|
||||
blacklistToggle: 'Trefwoordfilter',
|
||||
genreMixTitle: 'Genremix',
|
||||
genreMixDesc: 'Top 20 genres op aantal nummers — klik voor een willekeurige mix',
|
||||
genreMixLoadMore: '10 meer laden',
|
||||
genreMixNoGenres: 'Geen genres gevonden op server.',
|
||||
shuffleGenres: 'Andere genres tonen',
|
||||
filterPanelTitle: 'Filters',
|
||||
filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.',
|
||||
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (Artiest)',
|
||||
sortNewest: 'Nieuwste eerst',
|
||||
sortRandom: 'Willekeurig',
|
||||
yearFrom: 'Van',
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artiesten',
|
||||
search: 'Zoeken…',
|
||||
all: 'Alle',
|
||||
gridView: 'Rasterweergave',
|
||||
listView: 'Lijstweergave',
|
||||
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
|
||||
imagesOff: 'Artiestafbeeldingen uit — toont alleen initialen',
|
||||
loadMore: 'Meer laden',
|
||||
notFound: 'Geen artiesten gevonden.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Jouw Navidrome-desktopspeler',
|
||||
serverName: 'Servernaam (optioneel)',
|
||||
serverNamePlaceholder: 'Mijn Navidrome',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 of music.voorbeeld.nl',
|
||||
username: 'Gebruikersnaam',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Wachtwoord',
|
||||
showPassword: 'Wachtwoord tonen',
|
||||
hidePassword: 'Wachtwoord verbergen',
|
||||
connect: 'Verbinden',
|
||||
connecting: 'Verbinden…',
|
||||
connected: 'Verbonden!',
|
||||
error: 'Verbinding mislukt — controleer je gegevens.',
|
||||
urlRequired: 'Voer een server-URL in.',
|
||||
savedServers: 'Opgeslagen servers',
|
||||
addNew: 'Of een nieuwe server toevoegen',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Verbonden',
|
||||
connectedTo: 'Verbonden met {{server}}',
|
||||
disconnected: 'Verbroken',
|
||||
disconnectedFrom: 'Kan {{server}} niet bereiken — klik om instellingen te controleren',
|
||||
checking: 'Verbinden…',
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'Geen serververbinding',
|
||||
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
|
||||
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
|
||||
offlineLibraryTitle: 'Offline bibliotheek',
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Alles',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Afspeellijsten',
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
},
|
||||
common: {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
loading: 'Laden…',
|
||||
loadingMore: 'Laden…',
|
||||
loadingPlaylists: 'Afspeellijsten laden…',
|
||||
noAlbums: 'Geen albums gevonden.',
|
||||
downloading: 'Downloaden…',
|
||||
downloadZip: 'Downloaden (ZIP)',
|
||||
back: 'Terug',
|
||||
cancel: 'Annuleren',
|
||||
save: 'Opslaan',
|
||||
delete: 'Verwijderen',
|
||||
use: 'Gebruiken',
|
||||
add: 'Toevoegen',
|
||||
active: 'Actief',
|
||||
download: 'Downloaden',
|
||||
chooseDownloadFolder: 'Downloadmap kiezen',
|
||||
noFolderSelected: 'Geen map geselecteerd',
|
||||
rememberDownloadFolder: 'Deze map onthouden',
|
||||
filterGenre: 'Genre-filter',
|
||||
filterSearchGenres: 'Genres zoeken…',
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} beschikbaar',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
language: 'Taal',
|
||||
languageEn: 'Engels',
|
||||
languageDe: 'Duits',
|
||||
languageFr: 'Frans',
|
||||
languageNl: 'Nederlands',
|
||||
languageZh: 'Chinees',
|
||||
languageNb: 'Noors',
|
||||
languageRu: 'Russisch',
|
||||
font: 'Lettertype',
|
||||
theme: 'Thema',
|
||||
appearance: 'Weergave',
|
||||
servers: 'Servers',
|
||||
serverName: 'Servernaam',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUsername: 'Gebruikersnaam',
|
||||
serverPassword: 'Wachtwoord',
|
||||
addServer: 'Server toevoegen',
|
||||
addServerTitle: 'Nieuwe server toevoegen',
|
||||
useServer: 'Gebruiken',
|
||||
deleteServer: 'Verwijderen',
|
||||
noServers: 'Geen servers opgeslagen.',
|
||||
serverActive: 'Actief',
|
||||
confirmDeleteServer: 'Server "{{name}}" verwijderen?',
|
||||
serverConnecting: 'Verbinden…',
|
||||
serverConnected: 'Verbonden!',
|
||||
serverFailed: 'Verbinding mislukt.',
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbonden',
|
||||
failed: 'Mislukt',
|
||||
eqTitle: 'Equalizer',
|
||||
eqEnabled: 'Equalizer inschakelen',
|
||||
eqPreset: 'Voorinstelling',
|
||||
eqPresetCustom: 'Aangepast',
|
||||
eqPresetBuiltin: 'Ingebouwde voorinstellingen',
|
||||
eqPresetCustomGroup: 'Mijn voorinstellingen',
|
||||
eqSavePreset: 'Opslaan als voorinstelling',
|
||||
eqPresetName: 'Naam voorinstelling…',
|
||||
eqDeletePreset: 'Voorinstelling verwijderen',
|
||||
eqResetBands: 'Terugzetten naar vlak',
|
||||
eqPreGain: 'Voorversterking',
|
||||
eqResetPreGain: 'Voorversterking resetten',
|
||||
eqAutoEqTitle: 'AutoEQ Hoofdtelefoon Zoeken',
|
||||
eqAutoEqPlaceholder: 'Zoek hoofdtelefoon / IEM model…',
|
||||
eqAutoEqSearching: 'Zoeken…',
|
||||
eqAutoEqNoResults: 'Geen resultaten gevonden',
|
||||
eqAutoEqError: 'Zoeken mislukt',
|
||||
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
|
||||
eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Verbinden met Last.fm',
|
||||
lfmConnecting: 'Wachten op autorisatie…',
|
||||
lfmConfirm: 'Ik heb de app geautoriseerd',
|
||||
lfmConnected: 'Verbonden als',
|
||||
lfmDisconnect: 'Verbinding verbreken',
|
||||
lfmConnectDesc: 'Verbind je Last.fm-account om scrobbling en Nu bezig-updates rechtstreeks vanuit Psysonic in te schakelen — geen Navidrome-configuratie vereist.',
|
||||
lfmOpenBrowser: 'Er opent een browservenster. Autoriseer Psysonic op Last.fm en klik daarna op de knop hieronder.',
|
||||
lfmScrobbles: '{{n}} scrobbles',
|
||||
lfmMemberSince: 'Lid sinds {{year}}',
|
||||
scrobbleEnabled: 'Scrobbling ingeschakeld',
|
||||
scrobbleDesc: 'Nummers naar Last.fm sturen na 50% afspeeltijd',
|
||||
behavior: 'App-gedrag',
|
||||
cacheTitle: 'Max. opslaggrootte',
|
||||
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
||||
cacheUsedImages: 'Afbeeldingen:',
|
||||
cacheUsedOffline: 'Offline nummers:',
|
||||
cacheUsedHot: 'Schijfgebruik:',
|
||||
hotCacheTrackCount: 'Nummers in cache:',
|
||||
cacheMaxLabel: 'Max. grootte',
|
||||
cacheClearBtn: 'Cache wissen',
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
offlineDirTitle: 'Offline-bibliotheek (In-app)',
|
||||
offlineDirDesc: 'Opslaglocatie voor nummers die je offline beschikbaar maakt in Psysonic.',
|
||||
offlineDirDefault: 'Standaard (app-gegevens)',
|
||||
offlineDirChange: 'Map wijzigen',
|
||||
offlineDirClear: 'Terugzetten naar standaard',
|
||||
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
|
||||
hotCacheTitle: 'Warme afspeelcache',
|
||||
hotCacheAlphaBadge: 'Alpha',
|
||||
hotCacheDisclaimer: 'Laadt aankomende wachtrijtracks voor en bewaart eerdere. Indien ingeschakeld: schijfruimte en netwerk.',
|
||||
hotCacheDirDefault: 'Standaard (app-gegevens)',
|
||||
hotCacheDirChange: 'Map wijzigen',
|
||||
hotCacheDirClear: 'Terugzetten naar standaard',
|
||||
hotCacheDirHint: 'Een andere map kiest: de index in de app wordt gereset; oude bestanden blijven staan tot je ze verwijdert.',
|
||||
hotCacheEnabled: 'Warme afspeelcache inschakelen',
|
||||
hotCacheMaxMb: 'Maximale cachegrootte',
|
||||
hotCacheDebounce: 'Uitstel bij wachtrijwijziging',
|
||||
hotCacheDebounceImmediate: 'Direct',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Warme cache wissen',
|
||||
hiResTitle: 'Natieve hi-res-weergave',
|
||||
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
||||
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
showTrayIcon: 'Tray-pictogram weergeven',
|
||||
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.',
|
||||
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',
|
||||
discordAppleCoversDesc: 'Stuurt artiest- en albumnaam naar de Apple-zoek-API om een hoes te vinden voor je Discord-profiel. Standaard uitgeschakeld vanwege privacy.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
pickFolderTitle: 'Downloadmap selecteren',
|
||||
clearFolder: 'Map wissen',
|
||||
logout: 'Uitloggen',
|
||||
aboutTitle: 'Over Psysonic',
|
||||
aboutDesc: 'Een moderne desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Gebouwd op Tauri v2 met een native Rust audio-engine — licht en snel, maar boordevol functies: golfvorm-zoekbalk, gesynchroniseerde songteksten, Last.fm-integratie, 10-bands equalizer, crossfade, naadloos afspelen, Replay Gain, genres en een uitgebreide themabibliotheek.',
|
||||
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Syncteksten · 10-bands EQ · Crossfade & Gapless · Replay Gain · Genres · 63 thema\'s · 5 talen',
|
||||
aboutLicense: 'Licentie',
|
||||
aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.',
|
||||
aboutRepo: 'Broncode op GitHub',
|
||||
aboutVersion: 'Versie',
|
||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||
aboutContributorsLabel: 'Bijdragers',
|
||||
aboutSpecialThanksLabel: 'Speciale dank',
|
||||
changelog: 'Wijzigingslog',
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
|
||||
randomMixTitle: 'Willekeurige mix',
|
||||
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
||||
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
|
||||
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
|
||||
randomMixBlacklistAdd: 'Toevoegen',
|
||||
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
|
||||
randomMixHardcodedTitle: 'Ingebouwde trefwoorden (actief wanneer selectievakje is aangevinkt)',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Opslag & Downloads',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
tabInput: 'Invoer',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
shortcutListening: 'Druk op een toets…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Wissen',
|
||||
globalShortcutsTitle: 'Globale sneltoetsen',
|
||||
globalShortcutsNote: 'Werken systeembreed, ook als Psysonic op de achtergrond draait. Vereist Ctrl, Alt of Super als modifier.',
|
||||
shortcutPlayPause: 'Afspelen / Pauzeren',
|
||||
shortcutNext: 'Volgend nummer',
|
||||
shortcutPrev: 'Vorig nummer',
|
||||
shortcutVolumeUp: 'Volume omhoog',
|
||||
shortcutVolumeDown: 'Volume omlaag',
|
||||
shortcutSeekForward: '10s vooruitspoelen',
|
||||
shortcutSeekBackward: '10s terugspoelen',
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
backupImport: 'Instellingen importeren',
|
||||
backupImportDesc: 'Herstelt instellingen vanuit een .psybkp-bestand. De app wordt herladen na het importeren.',
|
||||
backupImportConfirm: 'Alle huidige instellingen worden overschreven. Doorgaan?',
|
||||
backupSuccess: 'Back-up opgeslagen',
|
||||
backupImportSuccess: 'Instellingen hersteld — herladen…',
|
||||
backupImportError: 'Ongeldig of beschadigd back-upbestand.',
|
||||
playbackTitle: 'Afspelen',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Nummervolume normaliseren met ReplayGain-metadata',
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Nummer',
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
||||
preloadCustom: 'Aangepast',
|
||||
preloadCustomSeconds: 'Seconden voor einde: {{n}}',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Wat is nieuw',
|
||||
dontShowAgain: 'Niet meer weergeven',
|
||||
close: 'Begrepen',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
s1: 'Aan de slag',
|
||||
q1: 'Welke servers zijn compatibel?',
|
||||
a1: 'Psysonic werkt met elke Subsonic-compatibele server: Navidrome, Gonic, Subsonic, Airsonic en anderen. Navidrome is de aanbevolen keuze.',
|
||||
q2: 'Hoe verbind ik me met mijn server?',
|
||||
a2: 'Open Instellingen en klik op "Server toevoegen". Voer de server-URL in (bijv. 192.168.1.100:4533), je gebruikersnaam en wachtwoord. Psysonic test de verbinding voor het opslaan.',
|
||||
q3: 'Kan ik meerdere servers gebruiken?',
|
||||
a3: 'Ja. Je kunt zoveel servers toevoegen als je wilt in Instellingen en er op elk moment tussen wisselen. Slechts één server is tegelijk actief.',
|
||||
s2: 'Afspelen',
|
||||
q4: 'Hoe speel ik muziek af?',
|
||||
a4: 'Dubbelklik op een nummer om het af te spelen. Gebruik op album- en artiestpagina\'s "Alles afspelen" om het hele album te starten. Je kunt ook nummers naar de wachtrij slepen.',
|
||||
q5: 'Welke sneltoetsen zijn beschikbaar?',
|
||||
a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op alle platforms. Gebruik op Linux de knoppen van de spelerbalk. In-app sneltoetsen en systeembrede globale sneltoetsen zijn aan te passen via Instellingen → Sneltoetsen / Globale sneltoetsen.',
|
||||
q6: 'Wat is de wachtrij?',
|
||||
a6: 'De wachtrij toont alle komende nummers. Open deze met het paneelpictogram rechtsboven. Je kunt nummers herordenen door te slepen, shufflen en de wachtrij opslaan als afspeellijst.',
|
||||
q7: 'Hoe open ik de volledig-schermspeler?',
|
||||
a7: 'Klik op de albumhoes in de spelerbalk onderaan, of het uitvouwpictogram ernaast. Druk op Escape om te sluiten.',
|
||||
q8: 'Hoe werkt herhalen?',
|
||||
a8: 'Klik op de herhalknop om te wisselen: Uit → Alles herhalen → Één herhalen.',
|
||||
s3: 'Bibliotheek',
|
||||
q9: 'Hoe download ik een album?',
|
||||
a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server comprimeert het album eerst naar een ZIP-bestand — voor grote albums of verliesvrije bestanden (FLAC / WAV) kan het even duren voordat de download daadwerkelijk start. De voortgangsbalk verschijnt pas als de server klaar is met inpakken.',
|
||||
q10: 'Hoe markeer ik nummers en albums als favoriet?',
|
||||
a10: 'Klik op het sterpictogram op een nummervak of de albumkoptekst. Gemarkeerde items verschijnen in de sectie Favorieten in de zijbalk.',
|
||||
q11: 'Wat is de hero-carrousel op de startpagina?',
|
||||
a11: 'De banner bovenaan de startpagina kiest willekeurig albums uit je bibliotheek en wisselt deze elke 10 seconden af. Klik op de puntjes om naar een specifiek album te springen.',
|
||||
s4: 'Instellingen',
|
||||
q12: 'Hoe verander ik het thema?',
|
||||
a12: 'Instellingen → Thema. Een ruime keuze aan thema\'s in 8 groepen: Psysonic Themes, Mediaplayer, Besturingssystemen, Games, Films, Series, Social Media en Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
|
||||
q13: 'Hoe verander ik de taal?',
|
||||
a13: 'Instellingen → Taal. Engels, Duits, Frans, Nederlands en Chinees worden momenteel ondersteund.',
|
||||
q15: 'Hoe stel ik een downloadmap in?',
|
||||
a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'Hoe werkt scrobbling?',
|
||||
a16: 'Psysonic scrobbelt rechtstreeks naar Last.fm — geen Navidrome-configuratie nodig. Verbind je Last.fm-account in Instellingen → Last.fm en schakel scrobbling in.',
|
||||
q17: 'Wanneer wordt een scrobble verstuurd?',
|
||||
a17: 'Een scrobble wordt verstuurd nadat je 50% van een nummer hebt geluisterd.',
|
||||
q22: 'Wat is de golfvorm in de spelerbalk?',
|
||||
a22: 'De golfvormzoekbalk vervangt de klassieke voortgangsschuifregelaar. Klik ergens om naar die positie te springen, of sleep om door het nummer te navigeren.',
|
||||
q24: 'Kan ik de wachtrij shufflen?',
|
||||
a24: 'Ja. Open het wachtrijpaneel en klik op het shufflepictogram. Het huidige nummer blijft op positie 1 — alle overige nummers worden willekeurig geherordend.',
|
||||
q25: 'Openen Last.fm- en Wikipedia-links in een browser?',
|
||||
a25: 'Ja — ze openen in je standaard systeembrowser. De knop toont kort een bevestiging bij klikken.',
|
||||
s7: 'Willekeurige mix',
|
||||
q26: 'Wat is de Willekeurige mix?',
|
||||
a26: 'De Willekeurige mix bouwt een afspeellijst van willekeurige nummers uit je hele bibliotheek. Open het via "Willekeurige mix" in de zijbalk en klik op "Nieuwe mix".',
|
||||
q27: 'Wat is het Trefwoordfilter?',
|
||||
a27: 'Het Trefwoordfilter sluit nummers uit waarvan het genre, de titel of de albumnaam bepaalde woorden bevat. Luisterboeken worden automatisch gefilterd. Voeg aangepaste trefwoorden toe in Instellingen → Willekeurige mix.',
|
||||
q28: 'Wat is de Super-genremix?',
|
||||
a28: 'De Super-genremix groepeert je bibliotheek in brede categorieën (Rock, Metal, Elektronisch, Jazz, Klassiek, enz.) en bouwt een gerichte mix van die stijl. Selecteer een categoriechip onder de lijst.',
|
||||
s6: 'Probleemoplossing',
|
||||
q18: 'Albumhoezen en artiestenafbeeldingen laden langzaam.',
|
||||
a18: 'Afbeeldingen worden bij het eerste laden van de schijf van je server opgehaald en vervolgens 30 dagen lokaal gecachet. Volgende bezoeken zijn direct.',
|
||||
q19: 'De verbindingstest mislukt.',
|
||||
a19: 'Controleer de URL inclusief de poort (bijv. http://192.168.1.100:4533). Zorg dat geen firewall de verbinding blokkeert. Probeer http:// in plaats van https:// op een lokaal netwerk.',
|
||||
q20: 'Geen audio op Linux.',
|
||||
a20: 'Psysonic is beschikbaar als .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) en via AUR voor Arch/CachyOS (yay -S psysonic). Er is geen AppImage. Controleer of PipeWire of PulseAudio actief is als er geen audio is.',
|
||||
q21: 'De app toont een zwart scherm op Linux.',
|
||||
a21: 'Dit is meestal een GPU/EGL-driverprobleem in WebKitGTK. Start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Het AUR-pakket en officiële .deb/.rpm-installatieprogramma\'s doen dit automatisch.',
|
||||
q29: 'Wat zijn overgang (crossfade) en naadloos afspelen?',
|
||||
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (1–10 s) instelbaar.',
|
||||
q30: 'Toont Psysonic songteksten?',
|
||||
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
|
||||
q31: 'Kan ik sneltoetsen aanpassen?',
|
||||
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
|
||||
q32: 'Kan ik het lettertype wijzigen?',
|
||||
a32: 'Ja. Instellingen → Lettertype biedt 10 lettertypen waaronder IBM Plex Mono, Fira Code, JetBrains Mono en Courier Prime. Het gekozen lettertype geldt voor de gehele interface.',
|
||||
s8: 'Offlinemodus',
|
||||
q34: 'Wat is de Offlinemodus?',
|
||||
a34: 'De Offlinemodus (Beta) laat je albums op het apparaat opslaan zodat je zonder actieve serververbinding kunt luisteren. Gecachte nummers worden lokaal opgeslagen en direct van schijf afgespeeld — zonder netwerkverzoek.',
|
||||
q35: 'Hoe sla ik een album op voor offline gebruik?',
|
||||
a35: 'Open een album en klik op het downloadpictogram in de albumkoptekst. Psysonic downloadt alle nummers op de achtergrond. De voortgang wordt op de knop weergegeven. Na het cachen wordt het pictogram groen. Gecachte albums zijn te bekijken en verwijderen via de Offline bibliotheek (zijbalk).',
|
||||
q36: 'Hoeveel opslagruimte mag de offlinecache gebruiken?',
|
||||
a36: 'Je kunt een maximale cachegrootte instellen via Instellingen → Bibliotheek. Als de limiet is bereikt, verschijnt er een waarschuwingsbanner op de albumpagina. Je kunt afzonderlijke albums verwijderen in de Offline bibliotheek om ruimte vrij te maken.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Wachtrij',
|
||||
savePlaylist: 'Afspeellijst opslaan',
|
||||
updatePlaylist: 'Afspeellijst bijwerken',
|
||||
filterPlaylists: 'Afspeellijsten filteren…',
|
||||
playlistName: 'Naam afspeellijst',
|
||||
cancel: 'Annuleren',
|
||||
save: 'Opslaan',
|
||||
loadPlaylist: 'Afspeellijst laden',
|
||||
loading: 'Laden…',
|
||||
noPlaylists: 'Geen afspeellijsten gevonden.',
|
||||
load: 'Wachtrij vervangen & afspelen',
|
||||
appendToQueue: 'Toevoegen aan wachtrij',
|
||||
delete: 'Verwijderen',
|
||||
deleteConfirm: 'Afspeellijst "{{name}}" verwijderen?',
|
||||
clear: 'Leegmaken',
|
||||
shuffle: 'Wachtrij shufflen',
|
||||
gapless: 'Naadloos',
|
||||
crossfade: 'Overgang',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
emptyQueue: 'De wachtrij is leeg.',
|
||||
trackSingular: 'nummer',
|
||||
trackPlural: 'nummers',
|
||||
showRemaining: 'Resterende tijd tonen',
|
||||
showTotal: 'Totale tijd tonen',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistieken',
|
||||
recentlyPlayed: 'Recent afgespeeld',
|
||||
mostPlayed: 'Meest gespeelde albums',
|
||||
highestRated: 'Best beoordeelde albums',
|
||||
genreDistribution: 'Genreverdeling (Top 20)',
|
||||
loadMore: 'Meer laden',
|
||||
statArtists: 'Artiesten',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Nummers',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Totale speelduur',
|
||||
genreInsights: 'Genre-inzichten',
|
||||
formatDistribution: 'Formaatdistributie',
|
||||
formatSample: 'Steekproef van {{n}} nummers',
|
||||
computing: 'Berekenen…',
|
||||
genreSongs: '{{count}} nummers',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Recent toegevoegd',
|
||||
decadeDistribution: 'Albums per decennium',
|
||||
decadeAlbums_one: '{{count}} album',
|
||||
decadeAlbums_other: '{{count}} albums',
|
||||
decadeUnknown: 'Onbekend',
|
||||
lfmTitle: 'Last.fm-statistieken',
|
||||
lfmTopArtists: 'Topartiesten',
|
||||
lfmTopAlbums: 'Topalbums',
|
||||
lfmTopTracks: 'Topnummers',
|
||||
lfmPlays: '{{count}} keer gespeeld',
|
||||
lfmPeriodOverall: 'Alle tijd',
|
||||
lfmPeriod7day: '7 dagen',
|
||||
lfmPeriod1month: '1 maand',
|
||||
lfmPeriod3month: '3 maanden',
|
||||
lfmPeriod6month: '6 maanden',
|
||||
lfmPeriod12month: '12 maanden',
|
||||
lfmNotConnected: 'Verbind Last.fm in Instellingen om je statistieken te zien.',
|
||||
lfmRecentTracks: 'Recente scrobbles',
|
||||
lfmNowPlaying: 'Nu bezig',
|
||||
lfmJustNow: 'zojuist',
|
||||
lfmMinutesAgo: '{{n}} min geleden',
|
||||
lfmHoursAgo: '{{n}} uur geleden',
|
||||
lfmDaysAgo: '{{n}} dagen geleden',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Muziekspeler',
|
||||
openFullscreen: 'Volledig-schermspeler openen',
|
||||
fullscreen: 'Volledig-schermspeler',
|
||||
closeFullscreen: 'Volledig scherm sluiten',
|
||||
closeTooltip: 'Sluiten (Escape)',
|
||||
noTitle: 'Geen titel',
|
||||
stop: 'Stoppen',
|
||||
prev: 'Vorig nummer',
|
||||
play: 'Afspelen',
|
||||
pause: 'Pauzeren',
|
||||
next: 'Volgend nummer',
|
||||
repeat: 'Herhalen',
|
||||
repeatOff: 'Uit',
|
||||
repeatAll: 'Alles',
|
||||
repeatOne: 'Één',
|
||||
progress: 'Nummervoortgang',
|
||||
volume: 'Volume',
|
||||
toggleQueue: 'Wachtrij in-/uitschakelen',
|
||||
lyrics: 'Songtekst',
|
||||
fsLyricsToggle: 'Songtekst in volledig scherm',
|
||||
lyricsLoading: 'Songtekst laden…',
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
lyricsSourceServer: 'Bron: Server',
|
||||
lyricsSourceLrclib: 'Bron: LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Artiest',
|
||||
album: 'Album',
|
||||
albumArtist: 'Albumartiest',
|
||||
year: 'Jaar',
|
||||
genre: 'Genre',
|
||||
duration: 'Duur',
|
||||
track: 'Track',
|
||||
format: 'Formaat',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Samplefrequentie',
|
||||
bitDepth: 'Bitdiepte',
|
||||
channels: 'Kanalen',
|
||||
fileSize: 'Bestandsgrootte',
|
||||
path: 'Locatie',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nieuwe playlist',
|
||||
unnamed: 'Naamloze playlist',
|
||||
createName: 'Playlistnaam…',
|
||||
create: 'Aanmaken',
|
||||
cancel: 'Annuleren',
|
||||
empty: 'Nog geen playlists.',
|
||||
emptyPlaylist: 'Deze playlist is leeg.',
|
||||
addFirstSong: 'Voeg je eerste nummer toe',
|
||||
notFound: 'Playlist niet gevonden.',
|
||||
songs: '{{n}} nummers',
|
||||
playAll: 'Alles afspelen',
|
||||
shuffle: 'Willekeurig',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
back: 'Terug naar playlists',
|
||||
deletePlaylist: 'Verwijderen',
|
||||
confirmDelete: 'Nogmaals klikken om te bevestigen',
|
||||
removeSong: 'Uit playlist verwijderen',
|
||||
addSongs: 'Nummers toevoegen',
|
||||
searchPlaceholder: 'Doorzoek bibliotheek…',
|
||||
noResults: 'Geen resultaten.',
|
||||
suggestions: 'Aanbevolen nummers',
|
||||
noSuggestions: 'Geen suggesties beschikbaar.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Openbaar',
|
||||
privateLabel: 'Privé',
|
||||
editMeta: 'Playlist bewerken',
|
||||
editNamePlaceholder: 'Playlistnaam…',
|
||||
editCommentPlaceholder: 'Beschrijving toevoegen…',
|
||||
editPublic: 'Openbare playlist',
|
||||
editSave: 'Opslaan',
|
||||
editCancel: 'Annuleren',
|
||||
changeCover: 'Omslagafbeelding wijzigen',
|
||||
changeCoverLabel: 'Foto wijzigen',
|
||||
removeCover: 'Foto verwijderen',
|
||||
coverUpdated: 'Omslag bijgewerkt',
|
||||
metaSaved: 'Playlist bijgewerkt',
|
||||
downloadZip: 'Downloaden (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meest gespeeld',
|
||||
topArtists: 'Topkunstenaars',
|
||||
topAlbums: 'Topalbums',
|
||||
plays: '{{n}} keer gespeeld',
|
||||
sortMost: 'Meest gespeeld eerst',
|
||||
sortLeast: 'Minst gespeeld eerst',
|
||||
loadMore: 'Meer albums laden',
|
||||
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
empty: 'Geen radiostations geconfigureerd.',
|
||||
addStation: 'Station toevoegen',
|
||||
editStation: 'Bewerken',
|
||||
deleteStation: 'Station verwijderen',
|
||||
confirmDelete: 'Klik opnieuw om te bevestigen',
|
||||
stationName: 'Stationsnaam…',
|
||||
streamUrl: 'Stream-URL…',
|
||||
homepageUrl: 'Homepage-URL (optioneel)',
|
||||
save: 'Opslaan',
|
||||
cancel: 'Annuleren',
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage openen',
|
||||
changeCoverLabel: 'Cover wijzigen',
|
||||
removeCover: 'Cover verwijderen',
|
||||
browseDirectory: 'Gids doorzoeken',
|
||||
directoryPlaceholder: 'Stations zoeken…',
|
||||
noResults: 'Geen stations gevonden.',
|
||||
stationAdded: 'Station toegevoegd',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favorieten',
|
||||
sortManual: 'Handmatig',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Nieuwste',
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,878 @@
|
||||
/** Russian UI strings — natural phrasing for a music desktop app */
|
||||
export const ruTranslation = {
|
||||
sidebar: {
|
||||
library: 'Медиатека',
|
||||
mainstage: 'Для вас',
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
nowPlaying: 'Сейчас играет',
|
||||
system: 'Система',
|
||||
statistics: 'Статистика',
|
||||
settings: 'Настройки',
|
||||
help: 'Справка',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
starred: 'Личное избранное',
|
||||
recent: 'Недавно добавлено',
|
||||
mostPlayed: 'Чаще всего',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
discover: 'Обзор',
|
||||
loadMore: 'Ещё',
|
||||
discoverMore: 'Смотреть ещё',
|
||||
discoverArtists: 'Исполнители',
|
||||
discoverArtistsMore: 'Все исполнители',
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Альбом дня',
|
||||
playAlbum: 'Воспроизвести альбом',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Исполнитель, альбом или трек…',
|
||||
noResults: 'Ничего не найдено по запросу «{{query}}»',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
clearLabel: 'Очистить поиск',
|
||||
title: 'Поиск',
|
||||
resultsFor: 'Результаты по «{{query}}»',
|
||||
album: 'Альбом',
|
||||
advanced: 'Расширенный поиск',
|
||||
advancedSearchTerm: 'Поисковый запрос',
|
||||
advancedSearchPlaceholder: 'Название, альбом, исполнитель…',
|
||||
advancedGenre: 'Жанр',
|
||||
advancedAllGenres: 'Все жанры',
|
||||
advancedYear: 'Год',
|
||||
advancedYearFrom: 'от',
|
||||
advancedYearTo: 'до',
|
||||
advancedAll: 'Все',
|
||||
advancedSearch: 'Найти',
|
||||
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
||||
advancedNoResults: 'Ничего не найдено.',
|
||||
advancedGenreNote: 'Треки выбираются случайно в рамках жанра.',
|
||||
recentSearches: 'Недавние запросы',
|
||||
browse: 'Обзор',
|
||||
emptyHint: 'Что хочешь послушать?',
|
||||
genres: 'Жанры',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Кто сейчас слушает?',
|
||||
title: 'Кто сейчас слушает?',
|
||||
loading: 'Загрузка…',
|
||||
nobody: 'Сейчас никто не слушает.',
|
||||
minutesAgo: '{{n}} мин назад',
|
||||
nothingPlaying: 'Пока ничего не играет — включите трек.',
|
||||
aboutArtist: 'Об исполнителе',
|
||||
fromAlbum: 'Из этого альбома',
|
||||
viewAlbum: 'Просмотр альбома',
|
||||
goToArtist: 'Перейти к исполнителю',
|
||||
readMore: 'Читать далее',
|
||||
showLess: 'Свернуть',
|
||||
genreInfo: 'Жанр',
|
||||
trackInfo: 'О треке',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Играть сейчас',
|
||||
playNext: 'Играть следующим',
|
||||
addToQueue: 'В конец очереди',
|
||||
enqueueAlbum: 'Альбом в очередь',
|
||||
startRadio: 'Радио по похожим',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
favoriteArtist: 'Исполнитель в избранное',
|
||||
favoriteAlbum: 'Альбом в избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
unfavoriteArtist: 'Убрать исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Убрать альбом из избранного',
|
||||
removeFromQueue: 'Убрать из очереди',
|
||||
openAlbum: 'Открыть альбом',
|
||||
goToArtist: 'К исполнителю',
|
||||
download: 'Скачать (ZIP)',
|
||||
addToPlaylist: 'В плейлист',
|
||||
songInfo: 'Сведения о треке',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Назад',
|
||||
playAll: 'Воспроизвести всё',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
artistBio: 'Биография',
|
||||
download: 'Скачать (ZIP)',
|
||||
downloading: 'Загрузка…',
|
||||
cacheOffline: 'Сохранить офлайн',
|
||||
offlineCached: 'Доступно офлайн',
|
||||
offlineDownloading: 'Кэширование… ({{n}} из {{total}})',
|
||||
removeOffline: 'Удалить офлайн-копию',
|
||||
offlineStorageFull:
|
||||
'Место для офлайн закончилось (лимит {{mb}} МБ). Освободите место в офлайн-библиотеке или увеличьте лимит в настройках.',
|
||||
offlineStorageGoToLibrary: 'Офлайн-библиотека',
|
||||
offlineStorageGoToSettings: 'Настройки',
|
||||
favoriteAdd: 'В избранное',
|
||||
favoriteRemove: 'Убрать из избранного',
|
||||
favorite: 'Избранное',
|
||||
noBio: 'Биография недоступна.',
|
||||
moreByArtist: 'Ещё от {{artist}}',
|
||||
tracksCount: 'Композиций: {{n}}',
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackGenre: 'Жанр',
|
||||
trackFormat: 'Формат',
|
||||
trackFavorite: 'Избранное',
|
||||
trackRating: 'Оценка',
|
||||
trackDuration: 'Длительность',
|
||||
trackTotal: 'Итого',
|
||||
columns: 'Колонки',
|
||||
notFound: 'Альбом не найден.',
|
||||
bioModal: 'Биография исполнителя',
|
||||
bioClose: 'Закрыть',
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Назад',
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shuffle: 'Перемешать',
|
||||
radio: 'Радио',
|
||||
loading: 'Загрузка…',
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
albumsBy: 'Альбомы — {{name}}',
|
||||
topTracks: 'Популярные треки',
|
||||
noAlbums: 'Альбомов нет.',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
trackDuration: 'Длительность',
|
||||
favoriteAdd: 'В избранное',
|
||||
favoriteRemove: 'Убрать из избранного',
|
||||
favorite: 'Избранное',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
openedInBrowser: 'Открыто в браузере',
|
||||
featuredOn: 'Также участвует в',
|
||||
similarArtists: 'Похожие исполнители',
|
||||
cacheOffline: 'Сохранить дискографию офлайн',
|
||||
offlineCached: 'Дискография сохранена',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
uploadImage: 'Загрузить фото исполнителя',
|
||||
uploadImageError: 'Не удалось загрузить изображение',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Избранное',
|
||||
empty: 'Пока ничего не отмечено звёздочкой.',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
enqueueAll: 'Всё в очередь',
|
||||
playAll: 'Воспроизвести всё',
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
refresh: 'Обновить',
|
||||
},
|
||||
genres: {
|
||||
title: 'Жанры',
|
||||
genreCount: 'Жанры',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
loading: 'Загрузка жанров…',
|
||||
empty: 'Жанры не найдены.',
|
||||
albumsLoading: 'Загрузка альбомов…',
|
||||
albumsEmpty: 'В этом жанре альбомов нет.',
|
||||
loadMore: 'Ещё',
|
||||
back: 'Назад',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Новый микс',
|
||||
remixTooltip: 'Подобрать другие случайные треки',
|
||||
playAll: 'Воспроизвести всё',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackAlbum: 'Альбом',
|
||||
trackFavorite: 'Избранное',
|
||||
trackDuration: 'Длительность',
|
||||
favoriteAdd: 'В избранное',
|
||||
favoriteRemove: 'Убрать из избранного',
|
||||
play: 'Играть',
|
||||
trackGenre: 'Жанр',
|
||||
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
|
||||
excludeAudiobooksDesc:
|
||||
'По ключевым словам в жанре, названии, альбоме и исполнителе — например Hörbuch, Audiobook, Spoken Word и т. п.',
|
||||
genreBlocked: 'Слово отфильтровано',
|
||||
genreAddedToBlacklist: 'Добавлено в список фильтра',
|
||||
genreAlreadyBlocked: 'Уже в списке',
|
||||
artistBlocked: 'Исполнитель отфильтрован',
|
||||
artistAddedToBlacklist: 'Исполнитель добавлен в фильтр',
|
||||
artistClickHint: 'Нажмите, чтобы скрыть этого исполнителя',
|
||||
blacklistToggle: 'Фильтр по словам',
|
||||
genreMixTitle: 'Микс по жанру',
|
||||
genreMixDesc: 'Топ-20 жанров по числу треков — нажмите, чтобы собрать микс',
|
||||
genreMixLoadMore: 'Ещё 10 жанров',
|
||||
genreMixNoGenres: 'На сервере нет данных о жанрах.',
|
||||
shuffleGenres: 'Другие жанры',
|
||||
filterPanelTitle: 'Фильтры',
|
||||
filterPanelDesc:
|
||||
'Нажмите на тег жанра или имя исполнителя в списке ниже, чтобы исключить их из будущих миксов.',
|
||||
genreClickHint:
|
||||
'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Все альбомы',
|
||||
sortByName: 'А–Я (альбом)',
|
||||
sortByArtist: 'А–Я (исполнитель)',
|
||||
sortNewest: 'Сначала новые',
|
||||
sortRandom: 'Случайно',
|
||||
yearFrom: 'С',
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
},
|
||||
artists: {
|
||||
title: 'Исполнители',
|
||||
search: 'Поиск…',
|
||||
all: 'Все',
|
||||
gridView: 'Сетка',
|
||||
listView: 'Список',
|
||||
imagesOn: 'Фото исполнителей — больше трафика и нагрузка на систему',
|
||||
imagesOff: 'Только инициалы — экономнее по трафику',
|
||||
loadMore: 'Ещё',
|
||||
notFound: 'Исполнители не найдены.',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Десктопный клиент для Navidrome',
|
||||
serverName: 'Название сервера (необязательно)',
|
||||
serverNamePlaceholder: 'Мой Navidrome',
|
||||
serverUrl: 'Адрес сервера',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
|
||||
username: 'Логин',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Пароль',
|
||||
showPassword: 'Показать пароль',
|
||||
hidePassword: 'Скрыть пароль',
|
||||
connect: 'Подключиться',
|
||||
connecting: 'Подключение…',
|
||||
connected: 'Готово!',
|
||||
error: 'Не удалось подключиться — проверьте данные.',
|
||||
urlRequired: 'Укажите адрес сервера.',
|
||||
savedServers: 'Сохранённые серверы',
|
||||
addNew: 'Или добавить новый сервер',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Подключено',
|
||||
connectedTo: 'Сервер: {{server}}',
|
||||
disconnected: 'Нет связи',
|
||||
disconnectedFrom: 'Сервер {{server}} недоступен — нажмите для настроек',
|
||||
checking: 'Подключение…',
|
||||
extern: 'Внешний',
|
||||
offlineTitle: 'Нет связи с сервером',
|
||||
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
|
||||
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
offlineAlbumCount_one: '{{n}} альбом',
|
||||
offlineAlbumCount_few: '{{n}} альбома',
|
||||
offlineAlbumCount_many: '{{n}} альбомов',
|
||||
offlineAlbumCount_other: '{{n}} альбомов',
|
||||
offlineFilterAll: 'Всё',
|
||||
offlineFilterAlbums: 'Альбомы',
|
||||
offlineFilterPlaylists: 'Плейлисты',
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
common: {
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
loading: 'Загрузка…',
|
||||
loadingMore: 'Загрузка…',
|
||||
loadingPlaylists: 'Загрузка плейлистов…',
|
||||
noAlbums: 'Альбомов нет.',
|
||||
downloading: 'Скачивание…',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
back: 'Назад',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
delete: 'Удалить',
|
||||
use: 'Использовать',
|
||||
add: 'Добавить',
|
||||
active: 'Активен',
|
||||
download: 'Скачать',
|
||||
chooseDownloadFolder: 'Выбрать папку',
|
||||
noFolderSelected: 'Папка не выбрана',
|
||||
rememberDownloadFolder: 'Запомнить папку',
|
||||
filterGenre: 'Фильтр по жанру',
|
||||
filterSearchGenres: 'Поиск жанров…',
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
language: 'Язык',
|
||||
languageEn: 'Английский',
|
||||
languageDe: 'Немецкий',
|
||||
languageFr: 'Французский',
|
||||
languageNl: 'Нидерландский',
|
||||
languageZh: 'Китайский',
|
||||
languageNb: 'Норвежский',
|
||||
languageRu: 'Русский',
|
||||
languageRu2: 'Русский 2',
|
||||
font: 'Шрифт',
|
||||
theme: 'Тема',
|
||||
appearance: 'Оформление',
|
||||
servers: 'Серверы',
|
||||
serverName: 'Название',
|
||||
serverUrl: 'Адрес',
|
||||
serverUsername: 'Логин',
|
||||
serverPassword: 'Пароль',
|
||||
addServer: 'Добавить сервер',
|
||||
addServerTitle: 'Новый сервер',
|
||||
useServer: 'Выбрать',
|
||||
deleteServer: 'Удалить',
|
||||
noServers: 'Нет сохранённых серверов.',
|
||||
serverActive: 'Активен',
|
||||
confirmDeleteServer: 'Удалить сервер «{{name}}»?',
|
||||
serverConnecting: 'Подключение…',
|
||||
serverConnected: 'Подключено!',
|
||||
serverFailed: 'Ошибка подключения.',
|
||||
testBtn: 'Проверить',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Подключено',
|
||||
failed: 'Ошибка',
|
||||
eqTitle: 'Эквалайзер',
|
||||
eqEnabled: 'Включить эквалайзер',
|
||||
eqPreset: 'Пресет',
|
||||
eqPresetCustom: 'Свой',
|
||||
eqPresetBuiltin: 'Встроенные',
|
||||
eqPresetCustomGroup: 'Мои пресеты',
|
||||
eqSavePreset: 'Сохранить как пресет',
|
||||
eqPresetName: 'Название пресета…',
|
||||
eqDeletePreset: 'Удалить пресет',
|
||||
eqResetBands: 'Сбросить полосы',
|
||||
eqPreGain: 'Предусиление',
|
||||
eqResetPreGain: 'Сбросить предусиление',
|
||||
eqAutoEqTitle: 'AutoEQ — поиск профиля наушников',
|
||||
eqAutoEqPlaceholder: 'Модель наушников или IEM…',
|
||||
eqAutoEqSearching: 'Поиск…',
|
||||
eqAutoEqNoResults: 'Ничего не найдено',
|
||||
eqAutoEqError: 'Ошибка поиска',
|
||||
eqAutoEqRateLimit: 'Лимит GitHub — попробуйте через минуту',
|
||||
eqAutoEqFetchError: 'Не удалось загрузить профиль EQ',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Подключить Last.fm',
|
||||
lfmConnecting: 'Ожидание авторизации…',
|
||||
lfmConfirm: 'Я разрешил доступ',
|
||||
lfmConnected: 'Аккаунт',
|
||||
lfmDisconnect: 'Отключить',
|
||||
lfmConnectDesc:
|
||||
'Привяжите Last.fm для скробблинга и статуса «Сейчас слушаю» прямо из Psysonic — настраивать Navidrome не нужно.',
|
||||
lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.',
|
||||
lfmScrobbles: 'Скробблов: {{n}}',
|
||||
lfmMemberSince: 'С {{year}} года',
|
||||
scrobbleEnabled: 'Скробблинг включён',
|
||||
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% трека',
|
||||
behavior: 'Поведение',
|
||||
cacheTitle: 'Макс. размер кэша',
|
||||
cacheDesc:
|
||||
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Офлайн-альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
|
||||
cacheUsedImages: 'Изображения:',
|
||||
cacheUsedOffline: 'Офлайн-треки:',
|
||||
cacheUsedHot: 'На диске:',
|
||||
hotCacheTrackCount: 'Треков в кэше:',
|
||||
cacheMaxLabel: 'Лимит',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
||||
cacheClearConfirm: 'Очистить всё',
|
||||
cacheClearCancel: 'Отмена',
|
||||
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
|
||||
offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.',
|
||||
offlineDirDefault: 'По умолчанию (данные приложения)',
|
||||
offlineDirChange: 'Сменить папку',
|
||||
offlineDirClear: 'Сбросить по умолчанию',
|
||||
offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.',
|
||||
hotCacheTitle: 'Горячий кэш воспроизведения',
|
||||
hotCacheAlphaBadge: 'Альфа',
|
||||
hotCacheDisclaimer: 'Заранее подгружает следующие треки из очереди и сохраняет предыдущие. При включении использует место на диске и сеть.',
|
||||
hotCacheDirDefault: 'По умолчанию (данные приложения)',
|
||||
hotCacheDirChange: 'Сменить папку',
|
||||
hotCacheDirClear: 'Сбросить по умолчанию',
|
||||
hotCacheDirHint: 'При смене папки сбрасывается список в приложении; уже скачанные файлы остаются на старом пути, пока вы их не удалите.',
|
||||
hotCacheEnabled: 'Включить горячий кэш воспроизведения',
|
||||
hotCacheMaxMb: 'Максимальный размер кэша',
|
||||
hotCacheDebounce: 'Задержка после изменения очереди',
|
||||
hotCacheDebounceImmediate: 'Сразу',
|
||||
hotCacheDebounceSeconds: '{{n}} с',
|
||||
hotCacheClearBtn: 'Очистить горячий кэш',
|
||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||
showArtistImages: 'Фото исполнителей',
|
||||
showArtistImagesDesc:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
showTrayIcon: 'Иконка в трее',
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
pickFolderTitle: 'Папка для загрузок',
|
||||
clearFolder: 'Сбросить папку',
|
||||
logout: 'Выйти',
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc:
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
|
||||
aboutFeatures:
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
|
||||
aboutLicense: 'Лицензия',
|
||||
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
|
||||
aboutRepo: 'Исходный код на GitHub',
|
||||
aboutVersion: 'Версия',
|
||||
aboutBuiltWith: 'Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'При поддержке Claude Code (Anthropic)',
|
||||
aboutContributorsLabel: 'Участники',
|
||||
aboutSpecialThanksLabel: 'Особая благодарность',
|
||||
changelog: 'Список изменений',
|
||||
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
||||
showChangelogOnUpdateDesc: 'Автоматически при первом запуске новой версии.',
|
||||
randomMixTitle: 'Случайный микс',
|
||||
randomMixBlacklistTitle: 'Свои слова-фильтры',
|
||||
randomMixBlacklistDesc:
|
||||
'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).',
|
||||
randomMixBlacklistPlaceholder: 'Добавить слово…',
|
||||
randomMixBlacklistAdd: 'Добавить',
|
||||
randomMixBlacklistEmpty: 'Пока пусто.',
|
||||
randomMixHardcodedTitle: 'Встроенные слова (при включённом фильтре)',
|
||||
tabAudio: 'Звук',
|
||||
tabStorage: 'Хранилище и загрузки',
|
||||
tabAppearance: 'Внешний вид',
|
||||
homeCustomizerTitle: 'Главная',
|
||||
sidebarTitle: 'Боковая панель',
|
||||
sidebarReset: 'Сбросить порядок',
|
||||
sidebarDrag: 'Перетащите для порядка',
|
||||
sidebarFixed: 'Всегда показывать',
|
||||
tabInput: 'Ввод',
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
tabGeneral: 'Общие',
|
||||
backupTitle: 'Резервная копия',
|
||||
backupExport: 'Экспорт настроек',
|
||||
backupExportDesc:
|
||||
'Сохраняет настройки, серверы, Last.fm, тему, EQ и горячие клавиши в файл .psybkp. Пароли в открытом виде — храните файл в безопасности.',
|
||||
backupImport: 'Импорт настроек',
|
||||
backupImportDesc: 'Восстановить из .psybkp. Приложение перезапустится.',
|
||||
backupImportConfirm: 'Текущие настройки будут заменены. Продолжить?',
|
||||
backupSuccess: 'Копия сохранена',
|
||||
backupImportSuccess: 'Настройки восстановлены — перезапуск…',
|
||||
backupImportError: 'Файл повреждён или не подходит.',
|
||||
shortcutsReset: 'Сбросить',
|
||||
shortcutListening: 'Нажмите клавишу…',
|
||||
shortcutUnbound: '—',
|
||||
globalShortcutsTitle: 'Глобальные сочетания',
|
||||
globalShortcutsNote:
|
||||
'Работают, когда окно не в фокусе. Нужен модификатор: Ctrl, Alt или Super.',
|
||||
shortcutClear: 'Сбросить',
|
||||
shortcutPlayPause: 'Пауза / воспроизведение',
|
||||
shortcutNext: 'Следующий трек',
|
||||
shortcutPrev: 'Предыдущий трек',
|
||||
shortcutVolumeUp: 'Громче',
|
||||
shortcutVolumeDown: 'Тише',
|
||||
shortcutSeekForward: 'Вперёд на 10 с',
|
||||
shortcutSeekBackward: 'Назад на 10 с',
|
||||
shortcutToggleQueue: 'Показать / скрыть очередь',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Системный полный экран',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Выравнивание громкости по метаданным ReplayGain',
|
||||
replayGainMode: 'Режим',
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
notWithGapless: 'Недоступно при включённом режиме без пауз',
|
||||
notWithCrossfade: 'Недоступно при включённом кроссфейде',
|
||||
gapless: 'Без пауз между треками',
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
preloadEarly: 'Рано (через 5 с после старта)',
|
||||
preloadCustom: 'Свой интервал',
|
||||
preloadCustomSeconds: 'Секунд до конца: {{n}}',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
dontShowAgain: 'Больше не показывать',
|
||||
close: 'Понятно',
|
||||
},
|
||||
help: {
|
||||
title: 'Справка',
|
||||
s1: 'С чего начать',
|
||||
q1: 'Какие серверы подходят?',
|
||||
a1:
|
||||
'Любой с API Subsonic: Navidrome, Gonic, Subsonic, Airsonic и др. Для новых проектов чаще всего рекомендуют Navidrome.',
|
||||
q2: 'Как подключиться?',
|
||||
a2:
|
||||
'Настройки → «Добавить сервер»: адрес (например 192.168.1.100:4533), логин и пароль. Соединение проверяется до сохранения — при ошибке профиль не записывается.',
|
||||
q3: 'Можно несколько серверов?',
|
||||
a3: 'Да. Добавляйте сколько нужно и переключайтесь. Активен всегда один.',
|
||||
s2: 'Воспроизведение',
|
||||
q4: 'Как включить музыку?',
|
||||
a4:
|
||||
'Двойной щелчок по треку. На странице альбома или исполнителя — «Воспроизвести всё». Треки можно перетащить в панель очереди.',
|
||||
q5: 'Какие есть горячие клавиши?',
|
||||
a5:
|
||||
'Пробел — пауза/воспроизведение, Esc — закрыть полноэкранный плеер. Медиаклавиши работают на всех платформах; на Linux иногда удобнее кнопки в панели плеера. Свои сочетания — в Настройках.',
|
||||
q6: 'Что такое очередь?',
|
||||
a6:
|
||||
'Список следующих треков. Открывается иконкой справа вверху. Можно перетаскивать порядок, перемешивать и сохранять как плейлист.',
|
||||
q7: 'Как открыть полноэкранный плеер?',
|
||||
a7: 'Миниатюра обложки внизу или кнопка разворота рядом. Esc — выход.',
|
||||
q8: 'Как работает повтор?',
|
||||
a8: 'Кнопка повтора в панели: выкл. → все → один трек.',
|
||||
s3: 'Медиатека',
|
||||
q9: 'Как скачать альбом?',
|
||||
a9:
|
||||
'Страница альбома → «Скачать (ZIP)». Сервер сначала собирает архив — для больших альбомов или FLAC это может занять время; прогресс появится, когда начнётся передача файла.',
|
||||
q10: 'Как добавить в избранное?',
|
||||
a10: 'Звёздочка у трека или в шапке альбома. Всё избранное — в разделе слева.',
|
||||
q11: 'Что за карусель на главной?',
|
||||
a11:
|
||||
'Случайные альбомы из библиотеки, смена примерно каждые 10 с. Точки — перейти к слайду, клик по баннеру — открыть альбом.',
|
||||
s4: 'Настройки',
|
||||
q12: 'Как сменить тему?',
|
||||
a12: 'Настройки → Тема. Много готовых наборов в нескольких категориях.',
|
||||
q13: 'Как сменить язык?',
|
||||
a13:
|
||||
'Настройки → Язык: английский, немецкий, французский, нидерландский, китайский, норвежский, русский и русский (альтернативный, Русский 2).',
|
||||
q15: 'Куда сохраняются ZIP?',
|
||||
a15:
|
||||
'Настройки → Поведение → папка загрузок. Без выбора используется стандартная папка загрузок браузера/системы.',
|
||||
s5: 'Скробблинг',
|
||||
q16: 'Как устроен скробблинг?',
|
||||
a16:
|
||||
'Psysonic шлёт прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
|
||||
q17: 'Когда уходит скроббл?',
|
||||
a17: 'После прослушивания примерно половины трека.',
|
||||
q22: 'Что за волна внизу?',
|
||||
a22:
|
||||
'Волновая шкала вместо ползунка: клик — переход, перетаскивание — перемотка. Пройденный фрагмент подсвечен, дальше — буфер и остаток.',
|
||||
q24: 'Можно перемешать очередь?',
|
||||
a24: 'Да. В панели очереди — кнопка перемешивания. Текущий трек остаётся первым.',
|
||||
q25: 'Ссылки Last.fm и Wikipedia открываются в браузере?',
|
||||
a25: 'Да, в системном браузере по умолчанию. Кратко показывается подтверждение.',
|
||||
s7: 'Случайный микс',
|
||||
q26: 'Что такое случайный микс?',
|
||||
a26:
|
||||
'Случайный набор треков из всей библиотеки. Раздел в боковой панели → «Новый микс». Число треков можно задать заранее.',
|
||||
q27: 'Что за фильтр по словам?',
|
||||
a27:
|
||||
'Исключает треки, где в жанре, названии или альбоме есть заданные слова. Аудиокниги отсекаются автоматически. Свои слова — в настройках микса или кликом по тегу жанра в списке.',
|
||||
q28: 'Что за «супер-микс» по жанру?',
|
||||
a28:
|
||||
'Грубая группировка стилей (рок, метал, электроника и т. д.) и микс внутри выбранной группы. Результаты подгружаются по мере запросов.',
|
||||
s6: 'Если что-то не так',
|
||||
q18: 'Медленно грузятся обложки.',
|
||||
a18:
|
||||
'Первый раз картинки тянутся с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
|
||||
q19: 'Тест соединения не проходит.',
|
||||
a19:
|
||||
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локалки иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
|
||||
q20: 'Нет звука в Linux.',
|
||||
a20:
|
||||
'Есть пакеты .deb, .rpm и AUR. Убедитесь, что запущены PipeWire или PulseAudio.',
|
||||
q21: 'Чёрный экран в Linux.',
|
||||
a21:
|
||||
'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.',
|
||||
q29: 'Кроссфейд и воспроизведение без пауз?',
|
||||
a29:
|
||||
'Экспериментальные опции в Настройках → Звук. Режим без пауз заранее подгружает следующий трек. Кроссфейд плавно накладывает треки, длина 1–10 с.',
|
||||
q30: 'Есть тексты песен?',
|
||||
a30:
|
||||
'Да. Иконка микрофона в панели плеера — вкладка текстов в очереди. Источник LRCLIB; синхронные строки подсвечиваются по ходу трека.',
|
||||
q31: 'Можно переназначить клавиши?',
|
||||
a31: 'Да: Настройки → сочетания в приложении и глобальные (работают в фоне).',
|
||||
q32: 'Можно сменить шрифт?',
|
||||
a32: 'Да, в настройках шрифта — набор гарнитур на весь интерфейс.',
|
||||
s8: 'Офлайн',
|
||||
q34: 'Что такое офлайн-режим?',
|
||||
a34:
|
||||
'Кэш альбомов на устройстве для прослушивания без сервера. Воспроизведение идёт с диска.',
|
||||
q35: 'Как сохранить альбом офлайн?',
|
||||
a35:
|
||||
'Страница альбома → иконка загрузки в шапке. Прогресс на кнопке, после загрузки — индикатор готовности. Список — в офлайн-библиотеке.',
|
||||
q36: 'Сколько места занимает офлайн?',
|
||||
a36:
|
||||
'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь',
|
||||
savePlaylist: 'Сохранить как плейлист',
|
||||
updatePlaylist: 'Обновить плейлист',
|
||||
filterPlaylists: 'Фильтр плейлистов…',
|
||||
playlistName: 'Название плейлиста',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
loadPlaylist: 'Загрузить плейлист',
|
||||
loading: 'Загрузка…',
|
||||
noPlaylists: 'Плейлистов нет.',
|
||||
load: 'Заменить очередь и играть',
|
||||
appendToQueue: 'Добавить в очередь',
|
||||
delete: 'Удалить',
|
||||
deleteConfirm: 'Удалить плейлист «{{name}}»?',
|
||||
clear: 'Очистить',
|
||||
shuffle: 'Перемешать',
|
||||
gapless: 'Без пауз',
|
||||
crossfade: 'Кроссфейд',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
autoAdded: '— Добавлено автоматически —',
|
||||
radioAdded: '— Радио —',
|
||||
hide: 'Скрыть',
|
||||
close: 'Закрыть',
|
||||
nextTracks: 'Дальше',
|
||||
emptyQueue: 'Очередь пуста.',
|
||||
trackSingular: 'трек',
|
||||
trackPlural: 'треков',
|
||||
showRemaining: 'Осталось',
|
||||
showTotal: 'Всего',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Статистика',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
mostPlayed: 'Самые проигранные альбомы',
|
||||
highestRated: 'С высоким рейтингом',
|
||||
genreDistribution: 'Жанры (топ-20)',
|
||||
loadMore: 'Ещё',
|
||||
statArtists: 'Исполнители',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Треки',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Всего прослушано',
|
||||
genreInsights: 'Жанры подробнее',
|
||||
formatDistribution: 'Форматы',
|
||||
formatSample: 'Выборка {{n}} треков',
|
||||
computing: 'Считаем…',
|
||||
genreSongs_one: '{{count}} композиция',
|
||||
genreSongs_few: '{{count}} композиции',
|
||||
genreSongs_many: '{{count}} композиций',
|
||||
genreSongs_other: '{{count}} композиций',
|
||||
genreAlbums_one: '{{count}} альбом',
|
||||
genreAlbums_few: '{{count}} альбома',
|
||||
genreAlbums_many: '{{count}} альбомов',
|
||||
genreAlbums_other: '{{count}} альбомов',
|
||||
recentlyAdded: 'Недавно добавлено',
|
||||
decadeDistribution: 'Альбомы по десятилетиям',
|
||||
decadeAlbums_one: '{{count}} альбом',
|
||||
decadeAlbums_few: '{{count}} альбома',
|
||||
decadeAlbums_many: '{{count}} альбомов',
|
||||
decadeAlbums_other: '{{count}} альбомов',
|
||||
decadeUnknown: 'Неизвестно',
|
||||
lfmTitle: 'Статистика Last.fm',
|
||||
lfmTopArtists: 'Топ исполнителей',
|
||||
lfmTopAlbums: 'Топ альбомов',
|
||||
lfmTopTracks: 'Топ треков',
|
||||
lfmPlays_one: '{{count}} прослушивание',
|
||||
lfmPlays_few: '{{count}} прослушивания',
|
||||
lfmPlays_many: '{{count}} прослушиваний',
|
||||
lfmPlays_other: '{{count}} прослушиваний',
|
||||
lfmPeriodOverall: 'За всё время',
|
||||
lfmPeriod7day: '7 дней',
|
||||
lfmPeriod1month: 'Месяц',
|
||||
lfmPeriod3month: '3 месяца',
|
||||
lfmPeriod6month: '6 месяцев',
|
||||
lfmPeriod12month: 'Год',
|
||||
lfmNotConnected: 'Подключите Last.fm в настройках.',
|
||||
lfmRecentTracks: 'Последние скробблы',
|
||||
lfmNowPlaying: 'Сейчас играет',
|
||||
lfmJustNow: 'только что',
|
||||
lfmMinutesAgo: '{{n}} мин назад',
|
||||
lfmHoursAgo: '{{n}} ч назад',
|
||||
lfmDaysAgo: '{{n}} дн. назад',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Плеер',
|
||||
openFullscreen: 'Полноэкранный плеер',
|
||||
fullscreen: 'Полноэкранный режим',
|
||||
closeFullscreen: 'Выйти из полноэкранного',
|
||||
closeTooltip: 'Закрыть (Esc)',
|
||||
noTitle: 'Без названия',
|
||||
stop: 'Стоп',
|
||||
prev: 'Предыдущий трек',
|
||||
play: 'Играть',
|
||||
pause: 'Пауза',
|
||||
next: 'Следующий трек',
|
||||
repeat: 'Повтор',
|
||||
repeatOff: 'Выкл.',
|
||||
repeatAll: 'Все',
|
||||
repeatOne: 'Один',
|
||||
progress: 'Прогресс',
|
||||
volume: 'Громкость',
|
||||
toggleQueue: 'Очередь',
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
songTitle: 'Название',
|
||||
artist: 'Исполнитель',
|
||||
album: 'Альбом',
|
||||
albumArtist: 'Альбомный исполнитель',
|
||||
year: 'Год',
|
||||
genre: 'Жанр',
|
||||
duration: 'Длительность',
|
||||
track: 'Номер',
|
||||
format: 'Формат',
|
||||
bitrate: 'Битрейт',
|
||||
sampleRate: 'Частота',
|
||||
bitDepth: 'Разрядность',
|
||||
channels: 'Каналы',
|
||||
fileSize: 'Размер файла',
|
||||
path: 'Путь',
|
||||
replayGainTrack: 'RG по треку',
|
||||
replayGainAlbum: 'RG по альбому',
|
||||
replayGainPeak: 'Пик RG',
|
||||
mono: 'Моно',
|
||||
stereo: 'Стерео',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Плейлисты',
|
||||
newPlaylist: 'Новый плейлист',
|
||||
unnamed: 'Без названия',
|
||||
createName: 'Название…',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
empty: 'Плейлистов пока нет.',
|
||||
emptyPlaylist: 'Плейлист пуст.',
|
||||
addFirstSong: 'Добавьте первый трек',
|
||||
notFound: 'Плейлист не найден.',
|
||||
songs: 'Треков: {{n}}',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shuffle: 'Перемешать',
|
||||
addToQueue: 'В очередь',
|
||||
back: 'К списку плейлистов',
|
||||
deletePlaylist: 'Удалить',
|
||||
confirmDelete: 'Нажмите ещё раз для подтверждения',
|
||||
removeSong: 'Убрать из плейлиста',
|
||||
addSongs: 'Добавить треки',
|
||||
searchPlaceholder: 'Поиск по библиотеке…',
|
||||
noResults: 'Ничего не найдено.',
|
||||
suggestions: 'Рекомендации',
|
||||
noSuggestions: 'Пока нет рекомендаций.',
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'В плейлист',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
publicLabel: 'Публичный',
|
||||
privateLabel: 'Личный',
|
||||
editMeta: 'Изменить плейлист',
|
||||
editNamePlaceholder: 'Название…',
|
||||
editCommentPlaceholder: 'Описание…',
|
||||
editPublic: 'Публичный плейлист',
|
||||
editSave: 'Сохранить',
|
||||
editCancel: 'Отмена',
|
||||
changeCover: 'Сменить обложку',
|
||||
changeCoverLabel: 'Сменить фото',
|
||||
removeCover: 'Убрать фото',
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист сохранён',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
empty: 'Радиостанции не настроены.',
|
||||
addStation: 'Добавить станцию',
|
||||
editStation: 'Изменить',
|
||||
deleteStation: 'Удалить станцию',
|
||||
confirmDelete: 'Нажмите ещё раз для подтверждения',
|
||||
stationName: 'Название станции…',
|
||||
streamUrl: 'URL потока…',
|
||||
homepageUrl: 'Сайт станции (необязательно)',
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
live: 'ЭФИР',
|
||||
liveStream: 'Онлайн-радио',
|
||||
openHomepage: 'Открыть сайт',
|
||||
changeCoverLabel: 'Сменить обложку',
|
||||
removeCover: 'Убрать обложку',
|
||||
browseDirectory: 'Каталог станций',
|
||||
directoryPlaceholder: 'Поиск станций…',
|
||||
noResults: 'Станции не найдены.',
|
||||
stationAdded: 'Станция добавлена',
|
||||
filterAll: 'Все',
|
||||
filterFavorites: 'Избранное',
|
||||
sortManual: 'Вручную',
|
||||
sortAZ: 'А → Я',
|
||||
sortZA: 'Я → А',
|
||||
sortNewest: 'Сначала новые',
|
||||
favorite: 'В избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,820 @@
|
||||
export const zhTranslation = {
|
||||
sidebar: {
|
||||
library: '音乐库',
|
||||
mainstage: '主舞台',
|
||||
newReleases: '新发布',
|
||||
allAlbums: '全部专辑',
|
||||
randomAlbums: '随机专辑',
|
||||
artists: '艺术家',
|
||||
randomMix: '随机混音',
|
||||
favorites: '收藏夹',
|
||||
nowPlaying: '正在播放',
|
||||
system: '系统',
|
||||
statistics: '统计',
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
starred: '个人收藏',
|
||||
recent: '最近添加',
|
||||
mostPlayed: '最常播放',
|
||||
recentlyPlayed: '最近播放',
|
||||
discover: '发现',
|
||||
loadMore: '加载更多',
|
||||
discoverMore: '发现更多',
|
||||
discoverArtists: '发现艺术家',
|
||||
discoverArtistsMore: '全部艺术家'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: '精选专辑',
|
||||
playAlbum: '播放专辑',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜索艺术家、专辑或歌曲…',
|
||||
noResults: '未找到 "{{query}}" 的相关结果',
|
||||
artists: '艺术家',
|
||||
albums: '专辑',
|
||||
songs: '歌曲',
|
||||
clearLabel: '清除搜索',
|
||||
title: '搜索',
|
||||
resultsFor: '"{{query}}" 的搜索结果',
|
||||
album: '专辑',
|
||||
advanced: '高级搜索',
|
||||
advancedSearchTerm: '搜索词',
|
||||
advancedSearchPlaceholder: '标题、专辑、艺术家…',
|
||||
advancedGenre: '流派',
|
||||
advancedAllGenres: '所有流派',
|
||||
advancedYear: '年份',
|
||||
advancedYearFrom: '从',
|
||||
advancedYearTo: '至',
|
||||
advancedAll: '全部',
|
||||
advancedSearch: '搜索',
|
||||
advancedEmpty: '请输入搜索词或选择过滤器。',
|
||||
advancedNoResults: '未找到结果。',
|
||||
advancedGenreNote: '歌曲从该流派中随机选取。',
|
||||
recentSearches: '最近搜索',
|
||||
browse: '浏览',
|
||||
emptyHint: '你想听什么?',
|
||||
genres: '流派',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: '谁正在收听?',
|
||||
title: '谁正在收听?',
|
||||
loading: '加载中…',
|
||||
nobody: '当前无人收听。',
|
||||
minutesAgo: '{{n}} 分钟前',
|
||||
nothingPlaying: '尚未开始播放。开始播放一首歌曲吧!',
|
||||
aboutArtist: '关于艺术家',
|
||||
fromAlbum: '来自此专辑',
|
||||
viewAlbum: '查看专辑',
|
||||
goToArtist: '前往艺术家',
|
||||
readMore: '阅读更多',
|
||||
showLess: '收起',
|
||||
genreInfo: '流派',
|
||||
trackInfo: '曲目信息',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: '立即播放',
|
||||
playNext: '下一首播放',
|
||||
addToQueue: '添加到队列',
|
||||
enqueueAlbum: '专辑加入队列',
|
||||
startRadio: '开始电台',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
favoriteArtist: '收藏艺术家',
|
||||
favoriteAlbum: '收藏专辑',
|
||||
unfavorite: '取消收藏',
|
||||
unfavoriteArtist: '取消收藏艺术家',
|
||||
unfavoriteAlbum: '取消收藏专辑',
|
||||
removeFromQueue: '从队列中移除',
|
||||
openAlbum: '打开专辑',
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
back: '返回',
|
||||
playAll: '全部播放',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||
artistBio: '艺术家简介',
|
||||
download: '下载 (ZIP)',
|
||||
downloading: '加载中…',
|
||||
cacheOffline: '设为离线可用',
|
||||
offlineCached: '已离线缓存',
|
||||
offlineDownloading: '正在缓存… ({{n}}/{{total}})',
|
||||
removeOffline: '移除离线缓存',
|
||||
offlineStorageFull: '离线存储空间已满(限制:{{mb}} MB)。请从离线音乐库中删除专辑以释放空间,或在设置中增加限制。',
|
||||
offlineStorageGoToLibrary: '离线音乐库',
|
||||
offlineStorageGoToSettings: '设置',
|
||||
favoriteAdd: '添加到收藏',
|
||||
favoriteRemove: '从收藏中移除',
|
||||
favorite: '收藏',
|
||||
noBio: '暂无简介。',
|
||||
moreByArtist: '{{artist}} 的更多作品',
|
||||
tracksCount: '{{n}} 首曲目',
|
||||
goToArtist: '前往 {{artist}}',
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
trackFavorite: '收藏',
|
||||
trackRating: '评分',
|
||||
trackDuration: '时长',
|
||||
trackTotal: '总计',
|
||||
columns: '列',
|
||||
notFound: '未找到专辑。',
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
albums: '专辑',
|
||||
album: '专辑',
|
||||
playAll: '全部播放',
|
||||
shuffle: '随机播放',
|
||||
radio: '电台',
|
||||
loading: '加载中…',
|
||||
noRadio: '未找到该艺术家的相似曲目。',
|
||||
notFound: '未找到艺术家。',
|
||||
albumsBy: '{{name}} 的专辑',
|
||||
topTracks: '热门曲目',
|
||||
noAlbums: '未找到专辑。',
|
||||
trackTitle: '标题',
|
||||
trackAlbum: '专辑',
|
||||
trackDuration: '时长',
|
||||
favoriteAdd: '添加到收藏',
|
||||
favoriteRemove: '从收藏中移除',
|
||||
favorite: '收藏',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
albumCount_other: '{{count}} 张专辑',
|
||||
openedInBrowser: '已在浏览器中打开',
|
||||
featuredOn: '还出现在',
|
||||
similarArtists: '相似艺术家',
|
||||
cacheOffline: '离线保存全部专辑',
|
||||
offlineCached: '全部专辑已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
uploadImage: '上传艺术家图片',
|
||||
uploadImageError: '图片上传失败',
|
||||
},
|
||||
favorites: {
|
||||
title: '收藏夹',
|
||||
empty: '您还没有保存任何收藏。',
|
||||
artists: '艺术家',
|
||||
albums: '专辑',
|
||||
songs: '歌曲',
|
||||
enqueueAll: '全部加入队列',
|
||||
playAll: '全部播放',
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
refresh: '刷新',
|
||||
},
|
||||
genres: {
|
||||
title: '流派',
|
||||
genreCount: '个流派',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
albumCount_other: '{{count}} 张专辑',
|
||||
loading: '正在加载流派…',
|
||||
empty: '未找到流派。',
|
||||
albumsLoading: '正在加载专辑…',
|
||||
albumsEmpty: '未找到该流派的专辑。',
|
||||
loadMore: '加载更多',
|
||||
back: '返回',
|
||||
},
|
||||
randomMix: {
|
||||
title: '随机混音',
|
||||
remix: '重新混音',
|
||||
remixTooltip: '加载新的随机歌曲',
|
||||
playAll: '全部播放',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
trackAlbum: '专辑',
|
||||
trackFavorite: '收藏',
|
||||
trackDuration: '时长',
|
||||
favoriteAdd: '添加到收藏',
|
||||
favoriteRemove: '从收藏中移除',
|
||||
play: '播放',
|
||||
trackGenre: '流派',
|
||||
excludeAudiobooks: '排除有声读物和广播剧',
|
||||
excludeAudiobooksDesc: '根据流派、标题、专辑和艺术家匹配关键词 — 例如 Hörbuch、Audiobook、Spoken Word 等',
|
||||
genreBlocked: '关键词已屏蔽',
|
||||
genreAddedToBlacklist: '已添加到过滤列表',
|
||||
genreAlreadyBlocked: '已屏蔽',
|
||||
artistBlocked: '艺术家已屏蔽',
|
||||
artistAddedToBlacklist: '艺术家已添加到过滤列表',
|
||||
artistClickHint: '点击屏蔽此艺术家',
|
||||
blacklistToggle: '关键词过滤',
|
||||
genreMixTitle: '流派混音',
|
||||
genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音',
|
||||
genreMixLoadMore: '加载 10 首更多',
|
||||
genreMixNoGenres: '服务器上未找到流派。',
|
||||
shuffleGenres: '显示其他流派',
|
||||
filterPanelTitle: '过滤器',
|
||||
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
|
||||
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
|
||||
},
|
||||
albums: {
|
||||
title: '全部专辑',
|
||||
sortByName: '按名称排序 (A-Z)',
|
||||
sortByArtist: '按艺术家排序 (A-Z)',
|
||||
sortNewest: '最新优先',
|
||||
sortRandom: '随机排序',
|
||||
yearFrom: '从',
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
search: '搜索…',
|
||||
all: '全部',
|
||||
gridView: '网格视图',
|
||||
listView: '列表视图',
|
||||
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
|
||||
imagesOff: '艺术家图片已关闭 — 仅显示首字母',
|
||||
loadMore: '加载更多',
|
||||
notFound: '未找到艺术家。',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
albumCount_other: '{{count}} 张专辑',
|
||||
},
|
||||
login: {
|
||||
subtitle: '您的 Navidrome 桌面播放器',
|
||||
serverName: '服务器名称(可选)',
|
||||
serverNamePlaceholder: '我的 Navidrome',
|
||||
serverUrl: '服务器地址',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 music.example.com',
|
||||
username: '用户名',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: '密码',
|
||||
showPassword: '显示密码',
|
||||
hidePassword: '隐藏密码',
|
||||
connect: '连接',
|
||||
connecting: '正在连接…',
|
||||
connected: '已连接!',
|
||||
error: '连接失败 — 请检查您的信息。',
|
||||
urlRequired: '请输入服务器地址。',
|
||||
savedServers: '已保存的服务器',
|
||||
addNew: '或添加新服务器',
|
||||
},
|
||||
connection: {
|
||||
connected: '已连接',
|
||||
connectedTo: '已连接到 {{server}}',
|
||||
disconnected: '已断开连接',
|
||||
disconnectedFrom: '无法连接到 {{server}} — 点击检查设置',
|
||||
checking: '正在连接…',
|
||||
extern: '外部',
|
||||
offlineTitle: '无服务器连接',
|
||||
offlineSubtitle: '无法连接到 {{server}}。请检查您的网络或服务器。',
|
||||
offlineModeBanner: '离线模式 — 正在从本地缓存播放',
|
||||
offlineLibraryTitle: '离线音乐库',
|
||||
offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。',
|
||||
offlineAlbumCount_one: '{{n}} 张专辑',
|
||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||
retry: '重试',
|
||||
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
||||
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
||||
},
|
||||
common: {
|
||||
albums: '专辑',
|
||||
album: '专辑',
|
||||
loading: '加载中…',
|
||||
loadingMore: '加载中…',
|
||||
loadingPlaylists: '正在加载播放列表…',
|
||||
noAlbums: '未找到专辑。',
|
||||
downloading: '正在下载…',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
back: '返回',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
use: '使用',
|
||||
add: '添加',
|
||||
active: '当前使用',
|
||||
download: '下载',
|
||||
chooseDownloadFolder: '选择下载文件夹',
|
||||
noFolderSelected: '未选择文件夹',
|
||||
rememberDownloadFolder: '记住此文件夹',
|
||||
filterGenre: '流派筛选',
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已发布',
|
||||
updaterWebsite: '官网',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
language: '语言',
|
||||
languageEn: '英语',
|
||||
languageDe: '德语',
|
||||
languageFr: '法语',
|
||||
languageNl: '荷兰语',
|
||||
languageZh: '中文',
|
||||
languageNb: '挪威',
|
||||
languageRu: '俄语',
|
||||
font: '字体',
|
||||
theme: '主题',
|
||||
appearance: '外观',
|
||||
servers: '服务器',
|
||||
serverName: '服务器名称',
|
||||
serverUrl: '服务器地址',
|
||||
serverUsername: '用户名',
|
||||
serverPassword: '密码',
|
||||
addServer: '添加服务器',
|
||||
addServerTitle: '添加新服务器',
|
||||
useServer: '使用',
|
||||
deleteServer: '删除',
|
||||
noServers: '未保存任何服务器。',
|
||||
serverActive: '当前使用',
|
||||
confirmDeleteServer: '删除服务器 "{{name}}"?',
|
||||
serverConnecting: '正在连接…',
|
||||
serverConnected: '已连接!',
|
||||
serverFailed: '连接失败。',
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: '已连接',
|
||||
failed: '失败',
|
||||
eqTitle: '均衡器',
|
||||
eqEnabled: '启用均衡器',
|
||||
eqPreset: '预设',
|
||||
eqPresetCustom: '自定义',
|
||||
eqPresetBuiltin: '内置预设',
|
||||
eqPresetCustomGroup: '我的预设',
|
||||
eqSavePreset: '保存为预设',
|
||||
eqPresetName: '预设名称…',
|
||||
eqDeletePreset: '删除预设',
|
||||
eqResetBands: '重置为平直',
|
||||
eqPreGain: '预增益',
|
||||
eqResetPreGain: '重置预增益',
|
||||
eqAutoEqTitle: 'AutoEQ 耳机查询',
|
||||
eqAutoEqPlaceholder: '搜索耳机/入耳式型号…',
|
||||
eqAutoEqSearching: '搜索中…',
|
||||
eqAutoEqNoResults: '未找到结果',
|
||||
eqAutoEqError: '搜索失败',
|
||||
eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试',
|
||||
eqAutoEqFetchError: '无法获取 EQ 配置',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: '连接 Last.fm',
|
||||
lfmConnecting: '等待授权…',
|
||||
lfmConfirm: '我已完成授权',
|
||||
lfmConnected: '已连接为',
|
||||
lfmDisconnect: '断开连接',
|
||||
lfmConnectDesc: '连接您的 Last.fm 账户以启用歌曲记录和正在播放更新,直接从 Psysonic 发送 — 无需配置 Navidrome。',
|
||||
lfmOpenBrowser: '将打开浏览器窗口。在 Last.fm 上授权 Psysonic,然后点击下方按钮。',
|
||||
lfmScrobbles: '{{n}} 次记录',
|
||||
lfmMemberSince: '{{year}} 年加入',
|
||||
scrobbleEnabled: '启用歌曲记录',
|
||||
scrobbleDesc: '播放 50% 后发送歌曲到 Last.fm',
|
||||
behavior: '应用行为',
|
||||
cacheTitle: '最大存储大小',
|
||||
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
|
||||
cacheUsedImages: '图片:',
|
||||
cacheUsedOffline: '离线曲目:',
|
||||
cacheUsedHot: '占用空间:',
|
||||
hotCacheTrackCount: '缓存曲目数:',
|
||||
cacheMaxLabel: '最大容量',
|
||||
cacheClearBtn: '清除缓存',
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
offlineDirTitle: '离线音乐库(应用内)',
|
||||
offlineDirDesc: '用于在 Psysonic 中离线可用的曲目存储位置。',
|
||||
offlineDirDefault: '默认(应用数据)',
|
||||
offlineDirChange: '更改目录',
|
||||
offlineDirClear: '重置为默认',
|
||||
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
|
||||
hotCacheTitle: '热播放缓存',
|
||||
hotCacheAlphaBadge: '预览',
|
||||
hotCacheDisclaimer: '预加载队列中即将播放的曲目并保留近期已播放的。开启后占用磁盘与网络。',
|
||||
hotCacheDirDefault: '默认(应用数据)',
|
||||
hotCacheDirChange: '更改目录',
|
||||
hotCacheDirClear: '重置为默认',
|
||||
hotCacheDirHint: '更换目录会重置应用内索引;已下载的文件仍留在原路径,需自行删除。',
|
||||
hotCacheEnabled: '启用热播放缓存',
|
||||
hotCacheMaxMb: '最大缓存大小',
|
||||
hotCacheDebounce: '队列变更去抖',
|
||||
hotCacheDebounceImmediate: '立即',
|
||||
hotCacheDebounceSeconds: '{{n}} 秒',
|
||||
hotCacheClearBtn: '清空热缓存',
|
||||
hiResTitle: '原生高清晰度播放',
|
||||
hiResEnabled: '启用原生高清晰度播放',
|
||||
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
showTrayIcon: '显示托盘图标',
|
||||
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
|
||||
discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
pickFolderTitle: '选择下载文件夹',
|
||||
clearFolder: '清除下载文件夹',
|
||||
logout: '退出登录',
|
||||
aboutTitle: '关于 Psysonic',
|
||||
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的现代桌面音乐播放器。基于 Tauri v2 和原生 Rust 音频引擎构建——轻量快速,功能丰富:波形进度条、同步歌词、Last.fm 集成、10 段均衡器、交叉淡入淡出、无缝播放、响度增益、流派浏览以及大量精美主题。',
|
||||
aboutFeatures: '多服务器 · Last.fm 记录 & 喜爱 · 全屏环境舞台 · 波形跳转 · 同步歌词 · 10 段 EQ · 交叉淡入淡出 & 无缝播放 · 响度增益 · 流派 · 63 个主题 · 5 种语言',
|
||||
aboutLicense: '许可证',
|
||||
aboutLicenseText: 'GNU GPL v3 — 在相同许可证下可自由使用、修改和分发。',
|
||||
aboutRepo: 'GitHub 上的源代码',
|
||||
aboutVersion: '版本',
|
||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||
aboutContributorsLabel: '贡献者',
|
||||
aboutSpecialThanksLabel: '特别感谢',
|
||||
changelog: '更新日志',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
|
||||
randomMixTitle: '随机混音',
|
||||
randomMixBlacklistTitle: '自定义过滤关键词',
|
||||
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
|
||||
randomMixBlacklistPlaceholder: '添加关键词…',
|
||||
randomMixBlacklistAdd: '添加',
|
||||
randomMixBlacklistEmpty: '尚未添加自定义关键词。',
|
||||
randomMixHardcodedTitle: '内置关键词(复选框开启时生效)',
|
||||
tabAudio: '音频',
|
||||
tabStorage: '存储与下载',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
tabInput: '输入',
|
||||
tabServer: '服务器',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
backupImport: '导入设置',
|
||||
backupImportDesc: '从 .psybkp 文件恢复设置。导入后应用将重新加载。',
|
||||
backupImportConfirm: '所有当前设置将被覆盖。是否继续?',
|
||||
backupSuccess: '备份已保存',
|
||||
backupImportSuccess: '设置已恢复——正在重新加载…',
|
||||
backupImportError: '备份文件无效或已损坏。',
|
||||
shortcutsReset: '恢复默认',
|
||||
shortcutListening: '请按下按键…',
|
||||
shortcutUnbound: '—',
|
||||
globalShortcutsTitle: '全局快捷键',
|
||||
globalShortcutsNote: '即使 Psysonic 在后台也能在系统范围内工作。需要 Ctrl、Alt 或 Super 作为修饰键。',
|
||||
shortcutClear: '清除',
|
||||
shortcutPlayPause: '播放 / 暂停',
|
||||
shortcutNext: '下一首',
|
||||
shortcutPrev: '上一首',
|
||||
shortcutVolumeUp: '音量增大',
|
||||
shortcutVolumeDown: '音量减小',
|
||||
shortcutSeekForward: '快进 10 秒',
|
||||
shortcutSeekBackward: '快退 10 秒',
|
||||
shortcutToggleQueue: '切换队列',
|
||||
shortcutFullscreenPlayer: '全屏播放器',
|
||||
shortcutNativeFullscreen: '原生全屏',
|
||||
playbackTitle: '播放',
|
||||
replayGain: '回放增益',
|
||||
replayGainDesc: '使用 ReplayGain 元数据标准化曲目音量',
|
||||
replayGainMode: '模式',
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
notWithGapless: '无缝播放开启时不可用',
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
preloadEarly: '提前(播放5秒后)',
|
||||
preloadCustom: '自定义',
|
||||
preloadCustomSeconds: '结束前秒数:{{n}}',
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: '新功能',
|
||||
dontShowAgain: '不再显示',
|
||||
close: '知道了',
|
||||
},
|
||||
help: {
|
||||
title: '帮助',
|
||||
s1: '入门指南',
|
||||
q1: '支持哪些服务器?',
|
||||
a1: 'Psysonic 可与任何 Subsonic 兼容服务器配合使用:Navidrome、Gonic、Subsonic、Airsonic 等。推荐使用 Navidrome。',
|
||||
q2: '如何连接服务器?',
|
||||
a2: '打开设置并点击"添加服务器"。输入服务器地址(如 192.168.1.100:4533)、用户名和密码。Psysonic 会在保存前测试连接 — 如果连接失败则不会存储任何信息。',
|
||||
q3: '可以使用多个服务器吗?',
|
||||
a3: '可以。您可以在设置中添加任意数量的服务器,并随时切换。同一时间只能有一个服务器处于活动状态。',
|
||||
s2: '播放',
|
||||
q4: '如何播放音乐?',
|
||||
a4: '双击任意曲目即可播放。在专辑和艺术家页面,使用"全部播放"开始播放整张专辑。您也可以将曲目拖入队列面板。',
|
||||
q5: '有哪些键盘快捷键?',
|
||||
a5: '空格键 = 播放 / 暂停 · Esc = 关闭全屏播放器。媒体键(播放/暂停、下一首、上一首)在所有平台上都有效。在 Linux 上,请使用播放器栏按钮作为媒体键。应用内快捷键和系统级全局快捷键可在 设置 → 键盘快捷键 / 全局快捷键 中自定义。',
|
||||
q6: '什么是播放队列?',
|
||||
a6: '队列显示所有即将播放的曲目。点击右上角标题栏的面板图标(正在播放指示器旁边)打开队列。您可以通过拖拽重新排序,使用随机播放按钮打乱顺序,并将队列保存为播放列表。',
|
||||
q7: '如何打开全屏播放器?',
|
||||
a7: '点击底部播放器栏的专辑封面缩略图,或其旁边的展开图标。按 Esc 键关闭。',
|
||||
q8: '重复播放如何工作?',
|
||||
a8: '点击播放器栏的重复按钮可在以下模式间循环:关闭 → 重复全部 → 重复单曲。',
|
||||
s3: '音乐库',
|
||||
q9: '如何下载专辑?',
|
||||
a9: '打开专辑详情页并点击"下载 (ZIP)"。服务器会先将专辑压缩为 ZIP 文件 — 对于大型专辑或无损文件(FLAC / WAV),在实际下载开始前可能需要一些时间。这是正常的:进度条会在服务器完成压缩并开始传输后显示。',
|
||||
q10: '如何收藏曲目和专辑?',
|
||||
a10: '点击任意曲目行或专辑标题上的星形图标。收藏的项目会显示在侧边栏的收藏夹中。',
|
||||
q11: '首页的英雄轮播是什么?',
|
||||
a11: '首页顶部的横幅会随机从您的音乐库中选择专辑,每 10 秒轮换一次。点击圆点可跳转到特定专辑,或点击横幅打开专辑。',
|
||||
s4: '设置',
|
||||
q12: '如何更改主题?',
|
||||
a12: '设置 → 主题。从 8 个分组中的大量主题中选择:Psysonic 主题、媒体播放器、操作系统、游戏、电影、电视剧、社交媒体,以及开源经典(Catppuccin、Nord、Gruvbox、Nightfox)。',
|
||||
q13: '如何更改语言?',
|
||||
a13: '设置 → 语言。支持英语、德语、法语、荷兰语和中文。',
|
||||
q15: '如何设置下载文件夹?',
|
||||
a15: '设置 → 应用行为 → 下载文件夹。选择任意文件夹 — 下载的专辑将以 ZIP 文件形式保存在那里。未设置自定义文件夹时,将使用浏览器的默认下载位置。',
|
||||
s5: '歌曲记录',
|
||||
q16: '歌曲记录如何工作?',
|
||||
a16: 'Psysonic 直接向 Last.fm 发送记录 — 无需配置 Navidrome。在 设置 → Last.fm 中连接您的 Last.fm 账户并启用记录功能。',
|
||||
q17: '何时发送记录?',
|
||||
a17: '在您听完曲目的 50% 后提交记录。',
|
||||
q22: '播放器栏中的波形是什么?',
|
||||
a22: '波形进度条取代了传统的进度滑块。点击任意位置跳转到该位置,或拖动进行拖动播放。已播放部分显示蓝到紫的渐变,缓冲区域稍亮,未播放部分则淡化显示。',
|
||||
q24: '可以随机打乱队列吗?',
|
||||
a24: '可以。打开队列面板并点击队列标题栏的随机播放图标。当前播放曲目保持在第 1 位 — 其余曲目将随机重新排序。',
|
||||
q25: '艺术家页面上的 Last.fm 和 Wikipedia 链接会在浏览器中打开吗?',
|
||||
a25: '是的 — 它们会在您的默认系统浏览器中打开。点击时按钮会短暂显示确认标签。',
|
||||
s7: '随机混音',
|
||||
q26: '什么是随机混音?',
|
||||
a26: '随机混音从您的整个音乐库中构建一个随机曲目的播放列表。通过侧边栏的"随机混音"打开并点击"新混音"。您可以在生成前调整曲目数量。',
|
||||
q27: '什么是关键词过滤?',
|
||||
a27: '关键词过滤会排除流派、标题或专辑名称包含特定词汇的曲目。有声读物会被自动过滤。在 设置 → 随机混音 中添加自定义关键词,或点击曲目列表中的任意流派标签即时添加。',
|
||||
q28: '什么是超级流派混音?',
|
||||
a28: '超级流派混音将您的音乐库分组为宽泛的类别(摇滚、金属、电子、爵士、古典等),并从该风格构建一个专注的混音。在曲目列表下方选择类别标签。结果会随着每个子流派的获取而逐步显示。',
|
||||
s6: '故障排除',
|
||||
q18: '封面和艺术家图片加载缓慢。',
|
||||
a18: '图片首次加载时从服务器磁盘获取,然后本地缓存 30 天。如果服务器存储较慢,首次访问页面可能需要一些时间。后续访问将瞬间完成。',
|
||||
q19: '连接测试失败。',
|
||||
a19: '检查 URL 包括端口(如 http://192.168.1.100:4533)。确保防火墙没有阻止连接。在本地网络上尝试使用 http:// 而非 https://。同时验证用户名和密码是否正确。',
|
||||
q20: 'Linux 上没有音频。',
|
||||
a20: 'Psysonic 提供 .deb(Ubuntu/Debian)、.rpm(Fedora/RHEL)版本,以及通过 AUR 在 Arch/CachyOS 上安装(yay -S psysonic)。没有 AppImage。如果没有音频,请确保 PipeWire 或 PulseAudio 正在运行。',
|
||||
q21: '应用在 Linux 上显示黑屏。',
|
||||
a21: '这通常是 WebKitGTK 中的 GPU/EGL 驱动问题。使用 GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 启动。AUR 包和官方 .deb/.rpm 安装程序会自动设置这些环境变量。',
|
||||
q29: '什么是交叉淡入淡出和无缝播放?',
|
||||
a29: '两者都是 设置 → 音频 中的实验性音频功能。无缝播放预缓冲下一首曲目以消除歌曲间的静音。交叉淡入淡出让当前曲目淡出同时下一首淡入 — 调整持续时间(1-10 秒)以符合您的喜好。',
|
||||
q30: 'Psysonic 显示歌词吗?',
|
||||
a30: '是的。点击播放器栏的麦克风图标可在队列面板中打开歌词标签。Psysonic 自动从 LRCLIB 获取歌词。如果可用同步歌词,活动行会高亮并随歌曲实时滚动。如果只有纯文本歌词,则静态显示完整文本。',
|
||||
q31: '可以自定义键盘快捷键吗?',
|
||||
a31: '可以。设置 → 键盘快捷键 允许您重新映射应用内操作(播放/暂停、下一首、上一首、音量增/减、全屏等)到任意按键。设置 → 全局快捷键 允许您分配即使 Psysonic 在后台也能触发的系统级快捷键。',
|
||||
q32: '可以更改字体吗?',
|
||||
a32: '可以。设置 → 字体 允许您从 10 种字体中选择,包括 IBM Plex Mono、Fira Code、JetBrains Mono、Courier Prime 等。所选字体将应用于整个界面。',
|
||||
s8: '离线模式',
|
||||
q34: '什么是离线模式?',
|
||||
a34: '离线模式(测试版)允许您将专辑缓存到设备,以便在没有活动服务器连接时收听。缓存的曲目存储在本地并直接从磁盘播放 — 播放期间不进行网络请求。',
|
||||
q35: '如何缓存专辑以供离线使用?',
|
||||
a35: '打开任意专辑并点击专辑标题栏的下载图标。Psysonic 会在后台下载所有曲目。按钮上会显示进度。缓存完成后,图标变为绿色。您可以在离线音乐库页面(侧边栏)查看和移除缓存的专辑。',
|
||||
q36: '离线缓存可以使用多少存储空间?',
|
||||
a36: '您可以在 设置 → 音乐库 中设置最大缓存大小。达到限制时,专辑页面会显示警告横幅。您可以从离线音乐库中删除单个专辑以释放空间。',
|
||||
},
|
||||
queue: {
|
||||
title: '队列',
|
||||
savePlaylist: '保存为播放列表',
|
||||
updatePlaylist: '更新播放列表',
|
||||
filterPlaylists: '筛选播放列表…',
|
||||
playlistName: '播放列表名称',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
loadPlaylist: '加载播放列表',
|
||||
loading: '加载中…',
|
||||
noPlaylists: '未找到播放列表。',
|
||||
load: '替换队列并播放',
|
||||
appendToQueue: '追加到队列',
|
||||
delete: '删除',
|
||||
deleteConfirm: '删除播放列表 "{{name}}"?',
|
||||
clear: '清空',
|
||||
shuffle: '随机打乱队列',
|
||||
gapless: '无缝播放',
|
||||
crossfade: '交叉淡入淡出',
|
||||
infiniteQueue: '无限队列',
|
||||
autoAdded: '— 自动添加 —',
|
||||
radioAdded: '— 收音机 —',
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
emptyQueue: '队列为空。',
|
||||
trackSingular: '首曲目',
|
||||
trackPlural: '首曲目',
|
||||
showRemaining: '显示剩余时间',
|
||||
showTotal: '显示总时间',
|
||||
},
|
||||
statistics: {
|
||||
title: '统计',
|
||||
recentlyPlayed: '最近播放',
|
||||
mostPlayed: '最常播放的专辑',
|
||||
highestRated: '评分最高的专辑',
|
||||
genreDistribution: '流派分布(前 20)',
|
||||
loadMore: '加载更多',
|
||||
statArtists: '艺术家',
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
computing: '计算中…',
|
||||
genreSongs: '{{count}} 首歌曲',
|
||||
genreAlbums: '{{count}} 张专辑',
|
||||
recentlyAdded: '最近添加',
|
||||
decadeDistribution: '按年代分布的专辑',
|
||||
decadeAlbums_one: '{{count}} 张专辑',
|
||||
decadeAlbums_other: '{{count}} 张专辑',
|
||||
decadeUnknown: '未知',
|
||||
lfmTitle: 'Last.fm 统计',
|
||||
lfmTopArtists: '热门艺术家',
|
||||
lfmTopAlbums: '热门专辑',
|
||||
lfmTopTracks: '热门曲目',
|
||||
lfmPlays: '{{count}} 次播放',
|
||||
lfmPeriodOverall: '全部时间',
|
||||
lfmPeriod7day: '7 天',
|
||||
lfmPeriod1month: '1 个月',
|
||||
lfmPeriod3month: '3 个月',
|
||||
lfmPeriod6month: '6 个月',
|
||||
lfmPeriod12month: '12 个月',
|
||||
lfmNotConnected: '在设置中连接 Last.fm 以查看您的统计。',
|
||||
lfmRecentTracks: '最近记录',
|
||||
lfmNowPlaying: '正在播放',
|
||||
lfmJustNow: '刚刚',
|
||||
lfmMinutesAgo: '{{n}} 分钟前',
|
||||
lfmHoursAgo: '{{n}} 小时前',
|
||||
lfmDaysAgo: '{{n}} 天前',
|
||||
},
|
||||
player: {
|
||||
regionLabel: '音乐播放器',
|
||||
openFullscreen: '打开全屏播放器',
|
||||
fullscreen: '全屏播放器',
|
||||
closeFullscreen: '关闭全屏',
|
||||
closeTooltip: '关闭 (Esc)',
|
||||
noTitle: '无标题',
|
||||
stop: '停止',
|
||||
prev: '上一首',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
next: '下一首',
|
||||
repeat: '重复',
|
||||
repeatOff: '关闭',
|
||||
repeatAll: '全部',
|
||||
repeatOne: '单曲',
|
||||
progress: '播放进度',
|
||||
volume: '音量',
|
||||
toggleQueue: '切换队列',
|
||||
lyrics: '歌词',
|
||||
fsLyricsToggle: '全屏歌词',
|
||||
lyricsLoading: '正在加载歌词…',
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
songTitle: '标题',
|
||||
artist: '艺术家',
|
||||
album: '专辑',
|
||||
albumArtist: '专辑艺术家',
|
||||
year: '年份',
|
||||
genre: '流派',
|
||||
duration: '时长',
|
||||
track: '曲目',
|
||||
format: '格式',
|
||||
bitrate: '比特率',
|
||||
sampleRate: '采样率',
|
||||
bitDepth: '位深度',
|
||||
channels: '声道',
|
||||
fileSize: '文件大小',
|
||||
path: '文件路径',
|
||||
replayGainTrack: 'RG 曲目增益',
|
||||
replayGainAlbum: 'RG 专辑增益',
|
||||
replayGainPeak: 'RG 曲目峰值',
|
||||
mono: '单声道',
|
||||
stereo: '立体声',
|
||||
},
|
||||
playlists: {
|
||||
title: '播放列表',
|
||||
newPlaylist: '新建播放列表',
|
||||
unnamed: '未命名播放列表',
|
||||
createName: '播放列表名称…',
|
||||
create: '创建',
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
addFirstSong: '添加第一首歌曲',
|
||||
notFound: '未找到播放列表。',
|
||||
songs: '{{n}} 首歌曲',
|
||||
playAll: '全部播放',
|
||||
shuffle: '随机播放',
|
||||
addToQueue: '添加到队列',
|
||||
back: '返回播放列表',
|
||||
deletePlaylist: '删除',
|
||||
confirmDelete: '再次点击确认删除',
|
||||
removeSong: '从播放列表中移除',
|
||||
addSongs: '添加歌曲',
|
||||
searchPlaceholder: '搜索音乐库…',
|
||||
noResults: '无结果。',
|
||||
suggestions: '推荐歌曲',
|
||||
noSuggestions: '暂无推荐。',
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
publicLabel: '公开',
|
||||
privateLabel: '私有',
|
||||
editMeta: '编辑播放列表',
|
||||
editNamePlaceholder: '播放列表名称…',
|
||||
editCommentPlaceholder: '添加描述…',
|
||||
editPublic: '公开播放列表',
|
||||
editSave: '保存',
|
||||
editCancel: '取消',
|
||||
changeCover: '更改封面图片',
|
||||
changeCoverLabel: '更改照片',
|
||||
removeCover: '删除照片',
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
topArtists: '热门艺术家',
|
||||
topAlbums: '热门专辑',
|
||||
plays: '播放 {{n}} 次',
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
empty: '未配置任何电台。',
|
||||
addStation: '添加电台',
|
||||
editStation: '编辑',
|
||||
deleteStation: '删除电台',
|
||||
confirmDelete: '再次点击确认',
|
||||
stationName: '电台名称…',
|
||||
streamUrl: '流地址…',
|
||||
homepageUrl: '主页地址(可选)',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
live: '直播',
|
||||
liveStream: '网络电台',
|
||||
openHomepage: '打开主页',
|
||||
changeCoverLabel: '更换封面',
|
||||
removeCover: '移除封面',
|
||||
browseDirectory: '搜索目录',
|
||||
directoryPlaceholder: '搜索电台…',
|
||||
noResults: '未找到电台。',
|
||||
stationAdded: '电台已添加',
|
||||
filterAll: '全部',
|
||||
filterFavorites: '收藏',
|
||||
sortManual: '手动',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: '最新',
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
|
||||
@@ -31,6 +32,7 @@ interface Results {
|
||||
export default function AdvancedSearch() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const qFromUrl = params.get('q') ?? '';
|
||||
const navigate = useNavigate();
|
||||
const psyDrag = useDragDrop();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
@@ -46,6 +48,7 @@ export default function AdvancedSearch() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [genreNote, setGenreNote] = useState(false);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
const runSearch = async (opts: SearchOpts) => {
|
||||
setLoading(true);
|
||||
@@ -109,9 +112,8 @@ export default function AdvancedSearch() {
|
||||
getGenres().then(data =>
|
||||
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
|
||||
).catch(() => {});
|
||||
const q = params.get('q') ?? '';
|
||||
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
|
||||
}, []);
|
||||
if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
|
||||
}, [musicLibraryFilterVersion, qFromUrl]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user