mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
40 Commits
app-v1.34.0
...
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 |
@@ -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:
|
||||
@@ -125,48 +122,6 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
# tauri-action auto-generates a latest.json using the pubkey from tauri.conf.json
|
||||
# as the signature value (wrong). Delete it so only our generate-update-manifest.js
|
||||
# output is ever used as the canonical update manifest.
|
||||
- name: remove tauri-action generated update manifest
|
||||
shell: bash
|
||||
run: |
|
||||
gh release delete-asset ${{ github.ref_name }} latest.json --yes || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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:
|
||||
@@ -219,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
|
||||
|
||||
+106
@@ -5,6 +5,112 @@ 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
|
||||
|
||||
@@ -32,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.
|
||||
@@ -92,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.34.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.34.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,90 +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 = {};
|
||||
|
||||
// A real minisign .sig file is multi-line and ~200+ chars.
|
||||
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
|
||||
function validateSignature(sig, platform, sigFile) {
|
||||
// Public keys start with RWT and are a single short base64 token
|
||||
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
|
||||
` Got: ${sig}\n` +
|
||||
` The TAURI_SIGNING_PUBLIC_KEY env var must never be used as the signature value.\n` +
|
||||
` Ensure the signing step correctly writes the .sig file from the private key.`
|
||||
);
|
||||
}
|
||||
if (sig.length < 80) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
validateSignature(signature, platform, sigFile);
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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.34.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.34.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" }
|
||||
|
||||
@@ -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.34.0",
|
||||
"version": "1.34.3",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -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",
|
||||
|
||||
+27
@@ -26,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';
|
||||
@@ -53,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';
|
||||
@@ -81,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(() => {
|
||||
@@ -283,6 +305,7 @@ function AppShell() {
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
@@ -461,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;
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
@@ -52,6 +53,7 @@ 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);
|
||||
@@ -59,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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -34,6 +35,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
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(); }, []);
|
||||
|
||||
@@ -50,7 +52,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
try { setResults(await search(q)); }
|
||||
finally { setLoading(false); }
|
||||
}, 300),
|
||||
[]
|
||||
[musicLibraryFilterVersion]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { frTranslation } from './locales/fr';
|
||||
import { nbTranslation } from './locales/nb';
|
||||
import { nlTranslation } from './locales/nl';
|
||||
import { ruTranslation } from './locales/ru';
|
||||
import { ru2Translation } from './locales/ru2';
|
||||
import { zhTranslation } from './locales/zh';
|
||||
|
||||
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
|
||||
@@ -22,7 +21,6 @@ i18n
|
||||
zh: { translation: zhTranslation },
|
||||
nb: { translation: nbTranslation },
|
||||
ru: { translation: ruTranslation },
|
||||
ru2: { translation: ru2Translation },
|
||||
},
|
||||
lng: savedLanguage,
|
||||
fallbackLng: 'en',
|
||||
|
||||
+42
-11
@@ -15,14 +15,14 @@ export const deTranslation = {
|
||||
help: 'Hilfe',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
updateAvailable: 'Update verfügbar',
|
||||
updateReady: '{{version}} ist bereit',
|
||||
updateLink: 'Zum Release →',
|
||||
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',
|
||||
@@ -328,12 +328,8 @@ export const deTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} ist bereit',
|
||||
updaterInstall: 'Installieren & Neustart',
|
||||
updaterDownloading: 'Wird geladen…',
|
||||
updaterInstalling: 'Wird installiert…',
|
||||
updaterDownload: 'Von GitHub herunterladen',
|
||||
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
|
||||
updaterVersion: 'v{{version}} verfügbar',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -345,7 +341,6 @@ export const deTranslation = {
|
||||
languageZh: 'Chinesisch',
|
||||
languageNb: 'Norwegisch',
|
||||
languageRu: 'Russisch',
|
||||
languageRu2: 'Russisch 2',
|
||||
font: 'Schriftart',
|
||||
theme: 'Design',
|
||||
appearance: 'Darstellung',
|
||||
@@ -405,6 +400,8 @@ export const deTranslation = {
|
||||
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.',
|
||||
@@ -416,6 +413,22 @@ export const deTranslation = {
|
||||
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',
|
||||
@@ -424,8 +437,12 @@ export const deTranslation = {
|
||||
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',
|
||||
@@ -548,7 +565,7 @@ export const deTranslation = {
|
||||
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).',
|
||||
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?',
|
||||
@@ -689,8 +706,11 @@ export const deTranslation = {
|
||||
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',
|
||||
@@ -758,6 +778,17 @@ export const deTranslation = {
|
||||
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',
|
||||
|
||||
+43
-11
@@ -15,14 +15,15 @@ export const enTranslation = {
|
||||
help: 'Help',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar',
|
||||
updateAvailable: 'Update available',
|
||||
updateReady: '{{version}} is ready',
|
||||
updateLink: 'Go to release →',
|
||||
|
||||
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',
|
||||
@@ -328,12 +329,8 @@ export const enTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is ready',
|
||||
updaterInstall: 'Install & Restart',
|
||||
updaterDownloading: 'Downloading…',
|
||||
updaterInstalling: 'Installing…',
|
||||
updaterDownload: 'Download from GitHub',
|
||||
updaterExperimentalHint: 'Auto-update is still in development',
|
||||
updaterVersion: 'v{{version}} is available',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -345,7 +342,6 @@ export const enTranslation = {
|
||||
languageZh: 'Chinese',
|
||||
languageNb: 'Norwegian',
|
||||
languageRu: 'Russian',
|
||||
languageRu2: 'Russian 2',
|
||||
font: 'Font',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
@@ -405,6 +401,8 @@ export const enTranslation = {
|
||||
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.',
|
||||
@@ -416,6 +414,22 @@ export const enTranslation = {
|
||||
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',
|
||||
@@ -424,8 +438,12 @@ export const enTranslation = {
|
||||
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',
|
||||
@@ -548,7 +566,7 @@ export const enTranslation = {
|
||||
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).',
|
||||
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?',
|
||||
@@ -689,8 +707,11 @@ export const enTranslation = {
|
||||
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',
|
||||
@@ -758,6 +779,17 @@ export const enTranslation = {
|
||||
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',
|
||||
|
||||
+42
-11
@@ -15,14 +15,14 @@ export const frTranslation = {
|
||||
help: 'Aide',
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
updateAvailable: 'Mise à jour disponible',
|
||||
updateReady: '{{version}} est prêt',
|
||||
updateLink: 'Voir la version →',
|
||||
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',
|
||||
@@ -328,12 +328,8 @@ export const frTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est prête',
|
||||
updaterInstall: 'Installer & Redémarrer',
|
||||
updaterDownloading: 'Téléchargement…',
|
||||
updaterInstalling: 'Installation…',
|
||||
updaterDownload: 'Télécharger depuis GitHub',
|
||||
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
|
||||
updaterVersion: 'v{{version}} est disponible',
|
||||
updaterWebsite: 'Site Web',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -345,7 +341,6 @@ export const frTranslation = {
|
||||
languageZh: 'Chinois',
|
||||
languageNb: 'Norvégien',
|
||||
languageRu: 'Russe',
|
||||
languageRu2: 'Russe 2',
|
||||
font: 'Police',
|
||||
theme: 'Thème',
|
||||
appearance: 'Apparence',
|
||||
@@ -405,6 +400,8 @@ export const frTranslation = {
|
||||
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.',
|
||||
@@ -416,6 +413,22 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -424,8 +437,12 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -548,7 +565,7 @@ export const frTranslation = {
|
||||
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).',
|
||||
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 ?',
|
||||
@@ -689,8 +706,11 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -758,6 +778,17 @@ export const frTranslation = {
|
||||
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',
|
||||
|
||||
+41
-10
@@ -15,14 +15,14 @@ export const nbTranslation = {
|
||||
help: 'Hjelp',
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
updateAvailable: 'Ny versjon er tilgjengelig',
|
||||
updateReady: '{{version}} er klar',
|
||||
updateLink: 'Hent nyeste versjon →',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
},
|
||||
home: {
|
||||
hero: 'Utvalgt',
|
||||
@@ -328,12 +328,8 @@ export const nbTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
|
||||
bulkClear: 'Tøm utvalg',
|
||||
updaterAvailable: 'Ny versjon er tilgjengelig',
|
||||
updaterVersion: 'v{{version}} er lansert',
|
||||
updaterInstall: 'Installer og start applikasjonen på nytt',
|
||||
updaterDownloading: 'Laster ned…',
|
||||
updaterInstalling: 'Installerer…',
|
||||
updaterDownload: 'Last ned fra GitHub',
|
||||
updaterExperimentalHint: 'Auto-oppdatering er fortsatt under utvikling',
|
||||
updaterVersion: 'v{{version}} er tilgjengelig',
|
||||
updaterWebsite: 'Nettsted',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
@@ -345,7 +341,6 @@ export const nbTranslation = {
|
||||
languageZh: 'Kinesisk',
|
||||
languageNb: 'Norsk',
|
||||
languageRu: 'Russisk',
|
||||
languageRu2: 'Russisk 2',
|
||||
font: 'Skrifttype',
|
||||
theme: 'Tema',
|
||||
appearance: 'Utseende',
|
||||
@@ -406,6 +401,8 @@ export const nbTranslation = {
|
||||
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.',
|
||||
@@ -417,14 +414,34 @@ export const nbTranslation = {
|
||||
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',
|
||||
@@ -688,8 +705,11 @@ export const nbTranslation = {
|
||||
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',
|
||||
@@ -757,6 +777,17 @@ export const nbTranslation = {
|
||||
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',
|
||||
|
||||
+42
-11
@@ -15,14 +15,14 @@ export const nlTranslation = {
|
||||
help: 'Help',
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
updateAvailable: 'Update beschikbaar',
|
||||
updateReady: '{{version}} is klaar',
|
||||
updateLink: 'Naar release →',
|
||||
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',
|
||||
@@ -328,12 +328,8 @@ export const nlTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} is klaar',
|
||||
updaterInstall: 'Installeren & Herstarten',
|
||||
updaterDownloading: 'Downloaden…',
|
||||
updaterInstalling: 'Installeren…',
|
||||
updaterDownload: 'Downloaden van GitHub',
|
||||
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
|
||||
updaterVersion: 'v{{version}} beschikbaar',
|
||||
updaterWebsite: 'Website',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -345,7 +341,6 @@ export const nlTranslation = {
|
||||
languageZh: 'Chinees',
|
||||
languageNb: 'Noors',
|
||||
languageRu: 'Russisch',
|
||||
languageRu2: 'Russisch 2',
|
||||
font: 'Lettertype',
|
||||
theme: 'Thema',
|
||||
appearance: 'Weergave',
|
||||
@@ -405,6 +400,8 @@ export const nlTranslation = {
|
||||
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.',
|
||||
@@ -416,6 +413,22 @@ export const nlTranslation = {
|
||||
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',
|
||||
@@ -424,8 +437,12 @@ export const nlTranslation = {
|
||||
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',
|
||||
@@ -548,7 +565,7 @@ export const nlTranslation = {
|
||||
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).',
|
||||
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?',
|
||||
@@ -689,8 +706,11 @@ export const nlTranslation = {
|
||||
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',
|
||||
@@ -758,6 +778,17 @@ export const nlTranslation = {
|
||||
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',
|
||||
|
||||
+66
-37
@@ -2,28 +2,28 @@
|
||||
export const ruTranslation = {
|
||||
sidebar: {
|
||||
library: 'Медиатека',
|
||||
mainstage: 'Mainstage',
|
||||
mainstage: 'Для вас',
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
nowPlaying: 'Сейчас слушают',
|
||||
nowPlaying: 'Сейчас играет',
|
||||
system: 'Система',
|
||||
statistics: 'Статистика',
|
||||
settings: 'Настройки',
|
||||
help: 'Справка',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
updateReady: '{{version}} готов к установке',
|
||||
updateLink: 'К релизу →',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
@@ -39,7 +39,7 @@ export const ruTranslation = {
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Альбом дня',
|
||||
playAlbum: 'Играть альбом',
|
||||
playAlbum: 'Воспроизвести альбом',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
},
|
||||
@@ -54,13 +54,13 @@ export const ruTranslation = {
|
||||
resultsFor: 'Результаты по «{{query}}»',
|
||||
album: 'Альбом',
|
||||
advanced: 'Расширенный поиск',
|
||||
advancedSearchTerm: 'Запрос',
|
||||
advancedSearchTerm: 'Поисковый запрос',
|
||||
advancedSearchPlaceholder: 'Название, альбом, исполнитель…',
|
||||
advancedGenre: 'Жанр',
|
||||
advancedAllGenres: 'Все жанры',
|
||||
advancedYear: 'Год',
|
||||
advancedYearFrom: 'с',
|
||||
advancedYearTo: 'по',
|
||||
advancedYearFrom: 'от',
|
||||
advancedYearTo: 'до',
|
||||
advancedAll: 'Все',
|
||||
advancedSearch: 'Найти',
|
||||
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
||||
@@ -77,12 +77,12 @@ export const ruTranslation = {
|
||||
loading: 'Загрузка…',
|
||||
nobody: 'Сейчас никто не слушает.',
|
||||
minutesAgo: '{{n}} мин назад',
|
||||
nothingPlaying: 'Пока тишина — включите трек.',
|
||||
nothingPlaying: 'Пока ничего не играет — включите трек.',
|
||||
aboutArtist: 'Об исполнителе',
|
||||
fromAlbum: 'С альбома',
|
||||
viewAlbum: 'Открыть альбом',
|
||||
goToArtist: 'К исполнителю',
|
||||
readMore: 'Подробнее',
|
||||
fromAlbum: 'Из этого альбома',
|
||||
viewAlbum: 'Просмотр альбома',
|
||||
goToArtist: 'Перейти к исполнителю',
|
||||
readMore: 'Читать далее',
|
||||
showLess: 'Свернуть',
|
||||
genreInfo: 'Жанр',
|
||||
trackInfo: 'О треке',
|
||||
@@ -110,7 +110,7 @@ export const ruTranslation = {
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Назад',
|
||||
playAll: 'Играть всё',
|
||||
playAll: 'Воспроизвести всё',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
artistBio: 'Биография',
|
||||
@@ -151,7 +151,7 @@ export const ruTranslation = {
|
||||
back: 'Назад',
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
playAll: 'Играть всё',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shuffle: 'Перемешать',
|
||||
radio: 'Радио',
|
||||
loading: 'Загрузка…',
|
||||
@@ -186,7 +186,7 @@ export const ruTranslation = {
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
enqueueAll: 'Всё в очередь',
|
||||
playAll: 'Играть всё',
|
||||
playAll: 'Воспроизвести всё',
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
@@ -212,7 +212,7 @@ export const ruTranslation = {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Новый микс',
|
||||
remixTooltip: 'Подобрать другие случайные треки',
|
||||
playAll: 'Играть всё',
|
||||
playAll: 'Воспроизвести всё',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackAlbum: 'Альбом',
|
||||
@@ -342,12 +342,8 @@ export const ruTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} готова',
|
||||
updaterInstall: 'Установить и перезапустить',
|
||||
updaterDownloading: 'Скачивание…',
|
||||
updaterInstalling: 'Установка…',
|
||||
updaterDownload: 'Скачать с GitHub',
|
||||
updaterExperimentalHint: 'Автообновление в разработке',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -421,6 +417,8 @@ export const ruTranslation = {
|
||||
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Офлайн-альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
|
||||
cacheUsedImages: 'Изображения:',
|
||||
cacheUsedOffline: 'Офлайн-треки:',
|
||||
cacheUsedHot: 'На диске:',
|
||||
hotCacheTrackCount: 'Треков в кэше:',
|
||||
cacheMaxLabel: 'Лимит',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
||||
@@ -432,6 +430,22 @@ export const ruTranslation = {
|
||||
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:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
@@ -439,13 +453,16 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Показывать текущий трек в профиле Discord. Нужен запущенный Discord.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас слушают»',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в виде ZIP на диск.',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
pickFolderTitle: 'Папка для загрузок',
|
||||
@@ -453,9 +470,9 @@ export const ruTranslation = {
|
||||
logout: 'Выйти',
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc:
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, gapless, Replay Gain, жанры и много тем оформления.',
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
|
||||
aboutFeatures:
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и gapless · Replay Gain · жанры · десятки тем · несколько языков',
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
|
||||
aboutLicense: 'Лицензия',
|
||||
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
|
||||
aboutRepo: 'Исходный код на GitHub',
|
||||
@@ -523,7 +540,7 @@ export const ruTranslation = {
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
notWithGapless: 'Недоступно при включённом gapless',
|
||||
notWithGapless: 'Недоступно при включённом режиме без пауз',
|
||||
notWithCrossfade: 'Недоступно при включённом кроссфейде',
|
||||
gapless: 'Без пауз между треками',
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
@@ -556,7 +573,7 @@ export const ruTranslation = {
|
||||
s2: 'Воспроизведение',
|
||||
q4: 'Как включить музыку?',
|
||||
a4:
|
||||
'Двойной щелчок по треку. На странице альбома или исполнителя — «Играть всё». Треки можно перетащить в панель очереди.',
|
||||
'Двойной щелчок по треку. На странице альбома или исполнителя — «Воспроизвести всё». Треки можно перетащить в панель очереди.',
|
||||
q5: 'Какие есть горячие клавиши?',
|
||||
a5:
|
||||
'Пробел — пауза/воспроизведение, Esc — закрыть полноэкранный плеер. Медиаклавиши работают на всех платформах; на Linux иногда удобнее кнопки в панели плеера. Свои сочетания — в Настройках.',
|
||||
@@ -580,7 +597,8 @@ export const ruTranslation = {
|
||||
q12: 'Как сменить тему?',
|
||||
a12: 'Настройки → Тема. Много готовых наборов в нескольких категориях.',
|
||||
q13: 'Как сменить язык?',
|
||||
a13: 'Настройки → Язык: английский, немецкий, французский, нидерландский, китайский, норвежский, русский.',
|
||||
a13:
|
||||
'Настройки → Язык: английский, немецкий, французский, нидерландский, китайский, норвежский, русский и русский (альтернативный, Русский 2).',
|
||||
q15: 'Куда сохраняются ZIP?',
|
||||
a15:
|
||||
'Настройки → Поведение → папка загрузок. Без выбора используется стандартная папка загрузок браузера/системы.',
|
||||
@@ -620,9 +638,9 @@ export const ruTranslation = {
|
||||
q21: 'Чёрный экран в Linux.',
|
||||
a21:
|
||||
'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.',
|
||||
q29: 'Кроссфейд и gapless?',
|
||||
q29: 'Кроссфейд и воспроизведение без пауз?',
|
||||
a29:
|
||||
'Экспериментальные опции в Настройках → Звук. Gapless заранее подгружает следующий трек. Кроссфейд плавно накладывает треки, длина 1–10 с.',
|
||||
'Экспериментальные опции в Настройках → Звук. Режим без пауз заранее подгружает следующий трек. Кроссфейд плавно накладывает треки, длина 1–10 с.',
|
||||
q30: 'Есть тексты песен?',
|
||||
a30:
|
||||
'Да. Иконка микрофона в панели плеера — вкладка текстов в очереди. Источник LRCLIB; синхронные строки подсвечиваются по ходу трека.',
|
||||
@@ -658,7 +676,7 @@ export const ruTranslation = {
|
||||
deleteConfirm: 'Удалить плейлист «{{name}}»?',
|
||||
clear: 'Очистить',
|
||||
shuffle: 'Перемешать',
|
||||
gapless: 'Gapless',
|
||||
gapless: 'Без пауз',
|
||||
crossfade: 'Кроссфейд',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
autoAdded: '— Добавлено автоматически —',
|
||||
@@ -783,7 +801,7 @@ export const ruTranslation = {
|
||||
addFirstSong: 'Добавьте первый трек',
|
||||
notFound: 'Плейлист не найден.',
|
||||
songs: 'Треков: {{n}}',
|
||||
playAll: 'Играть всё',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shuffle: 'Перемешать',
|
||||
addToQueue: 'В очередь',
|
||||
back: 'К списку плейлистов',
|
||||
@@ -814,6 +832,17 @@ export const ruTranslation = {
|
||||
removeCover: 'Убрать фото',
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист сохранён',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
|
||||
@@ -1,804 +0,0 @@
|
||||
/** Russian UI strings (alternative — kilyabin, PR #107) */
|
||||
export const ru2Translation = {
|
||||
sidebar: {
|
||||
library: 'Библиотека',
|
||||
mainstage: 'Главная',
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
nowPlaying: 'Сейчас играет',
|
||||
system: 'Система',
|
||||
statistics: 'Статистика',
|
||||
settings: 'Настройки',
|
||||
help: 'Помощь',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
updateReady: '{{version}} готово',
|
||||
updateLink: 'Перейти к релизу →',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
offlineLibrary: 'Офлайн библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
radio: 'Интернет радио',
|
||||
},
|
||||
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: 'Совпадения ключевых слов в жанре, названии, альбоме и исполнителе — например, Аудиокнига, Разговорный жанр, …',
|
||||
genreBlocked: 'Ключевое слово заблокировано',
|
||||
genreAddedToBlacklist: 'Добавлено в список фильтрации',
|
||||
genreAlreadyBlocked: 'Уже заблокировано',
|
||||
artistBlocked: 'Исполнитель заблокирован',
|
||||
artistAddedToBlacklist: 'Исполнитель добавлен в список фильтрации',
|
||||
artistClickHint: 'Нажмите, чтобы заблокировать исполнителя',
|
||||
blacklistToggle: 'Фильтр ключевых слов',
|
||||
genreMixTitle: 'Жанровый микс',
|
||||
genreMixDesc: 'Топ-20 жанров по количеству песен — нажмите для загрузки случайного микса',
|
||||
genreMixLoadMore: 'Загрузить еще 10',
|
||||
genreMixNoGenres: 'Жанры на сервере не найдены.',
|
||||
shuffleGenres: 'Показать другие жанры',
|
||||
filterPanelTitle: 'Фильтры',
|
||||
filterPanelDesc: 'Нажмите на жанровый тег или имя исполнителя в списке треков ниже, чтобы заблокировать их в будущих миксах.',
|
||||
genreClickHint: 'Нажмите на жанровый тег, чтобы добавить его\nкак ключевое слово фильтра.\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: 'URL сервера',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
|
||||
username: 'Имя пользователя',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Пароль',
|
||||
showPassword: 'Показать пароль',
|
||||
hidePassword: 'Скрыть пароль',
|
||||
connect: 'Подключиться',
|
||||
connecting: 'Подключение…',
|
||||
connected: 'Подключено!',
|
||||
error: 'Ошибка подключения — проверьте ваши данные.',
|
||||
urlRequired: 'Введите URL сервера.',
|
||||
savedServers: 'Сохраненные серверы',
|
||||
addNew: 'Или добавьте новый сервер',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Подключено',
|
||||
connectedTo: 'Подключено к {{server}}',
|
||||
disconnected: 'Отключено',
|
||||
disconnectedFrom: 'Не удается подключиться к {{server}} — нажмите для проверки настроек',
|
||||
checking: 'Подключение…',
|
||||
extern: 'Внешний',
|
||||
offlineTitle: 'Нет подключения к серверу',
|
||||
offlineSubtitle: 'Не удается подключиться к {{server}}. Проверьте вашу сеть или сервер.',
|
||||
offlineModeBanner: 'Офлайн-режим — воспроизведение из локального кэша',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty: 'Пока нет кэшированных альбомов. Подключитесь к сети, откройте альбом и нажмите "Сделать доступным офлайн".',
|
||||
offlineAlbumCount: '{{n}} альбом',
|
||||
offlineAlbumCount_few: '{{n}} альбома',
|
||||
offlineAlbumCount_many: '{{n}} альбомов',
|
||||
offlineAlbumCount_plural: '{{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: 'v{{version}} готово',
|
||||
updaterInstall: 'Установить и перезапустить',
|
||||
updaterDownloading: 'Загрузка…',
|
||||
updaterInstalling: 'Установка…',
|
||||
updaterDownload: 'Скачать с GitHub',
|
||||
updaterExperimentalHint: 'Автообновление все еще в разработке',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
language: 'Язык',
|
||||
languageEn: 'Английский',
|
||||
languageDe: 'Немецкий',
|
||||
languageFr: 'Французский',
|
||||
languageNl: 'Нидерландский',
|
||||
languageZh: 'Китайский',
|
||||
languageNb: 'Норвежский',
|
||||
languageRu: 'Русский',
|
||||
languageRu2: 'Русский 2',
|
||||
font: 'Шрифт',
|
||||
theme: 'Тема',
|
||||
appearance: 'Внешний вид',
|
||||
servers: 'Серверы',
|
||||
serverName: 'Имя сервера',
|
||||
serverUrl: 'URL сервера',
|
||||
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: 'Откроется окно браузера. Авторизуйте Psysonic на Last.fm, затем нажмите кнопку ниже.',
|
||||
lfmScrobbles: '{{n}} скробблов',
|
||||
lfmMemberSince: 'Участник с {{year}}',
|
||||
scrobbleEnabled: 'Скробблинг включен',
|
||||
scrobbleDesc: 'Отправлять песни на Last.fm после 50% воспроизведения',
|
||||
behavior: 'Поведение приложения',
|
||||
cacheTitle: 'Макс. размер хранилища',
|
||||
cacheDesc: 'Обложки и изображения исполнителей. При заполнении старые записи удаляются автоматически. Офлайн альбомы не удаляются автоматически, но будут удалены при ручной очистке кэша.',
|
||||
cacheUsedImages: 'Изображения:',
|
||||
cacheUsedOffline: 'Офлайн треки:',
|
||||
cacheMaxLabel: 'Макс. размер',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
cacheClearWarning: 'Это также удалит все офлайн альбомы из библиотеки.',
|
||||
cacheClearConfirm: 'Очистить все',
|
||||
cacheClearCancel: 'Отмена',
|
||||
offlineDirTitle: 'Офлайн библиотека (в приложении)',
|
||||
offlineDirDesc: 'Место хранения треков, которые вы делаете доступными офлайн в Psysonic.',
|
||||
offlineDirDefault: 'По умолчанию (данные приложения)',
|
||||
offlineDirChange: 'Изменить директорию',
|
||||
offlineDirClear: 'Сбросить по умолчанию',
|
||||
offlineDirHint: 'Новые загрузки будут использовать это место. Существующие загрузки останутся по своему исходному пути.',
|
||||
showArtistImages: 'Показать изображения исполнителей',
|
||||
showArtistImagesDesc: 'Загружать и отображать изображения исполнителей в обзоре исполнителей. Отключено по умолчанию для снижения дискового ввода-вывода сервера и сетевой нагрузки на больших библиотеках.',
|
||||
showTrayIcon: 'Показать значок в трее',
|
||||
showTrayIconDesc: 'Отображать значок Psysonic в области уведомлений системы / строке меню.',
|
||||
minimizeToTray: 'Свернуть в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна оставлять Psysonic работающим в системном трее вместо выхода.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Показывать текущий воспроизводимый трек в вашем профиле Discord. Требуется запущенный Discord.',
|
||||
nowPlayingEnabled: 'Показывать в "Сейчас играет"',
|
||||
nowPlayingEnabledDesc: 'Транслировать текущий воспроизводимый трек на сервер в режим просмотра слушателей. Отключите, чтобы прекратить отправку данных воспроизведения.',
|
||||
downloadsTitle: 'ZIP экспорт и архивирование',
|
||||
downloadsFolderDesc: 'Папка назначения для альбомов, которые вы скачиваете в виде ZIP файла на компьютер.',
|
||||
downloadsDefault: 'Папка загрузок по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
pickFolderTitle: 'Выберите папку загрузки',
|
||||
clearFolder: 'Очистить папку загрузки',
|
||||
logout: 'Выйти',
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc: 'Современный настольный музыкальный проигрыватель для серверов, совместимых с Subsonic (Navidrome, Gonic и другие). Построен на Tauri v2 с нативным аудио движком на Rust — легкий и быстрый, но с богатым функционалом: волновая форма для перемотки, синхронизированные тексты песен, интеграция с Last.fm, 10-полосный эквалайзер, кроссфейд, воспроизведение без пауз, Replay Gain, просмотр жанров и большая библиотека тем.',
|
||||
aboutFeatures: 'Многосерверность · Скробблинг и лайки Last.fm · Полноэкранный Ambient Stage · Перемотка по волновой форме · Синхронизированные тексты · 10-полосный эквалайзер · Кроссфейд и воспроизведение без пауз · Replay Gain · Жанры · 63 темы · 5 языков',
|
||||
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, тему, эквалайзер и привязки клавиш в файл .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: '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: 'Psysonic работает с любым сервером, совместимым с Subsonic: Navidrome, Gonic, Subsonic, Airsonic и другими. Navidrome — рекомендуемый выбор.',
|
||||
q2: 'Как подключиться к серверу?',
|
||||
a2: 'Откройте Настройки и нажмите "Добавить сервер". Введите URL сервера (например, 192.168.1.100:4533), имя пользователя и пароль. Psysonic проверяет подключение перед сохранением — ничего не сохраняется, если подключение не удалось.',
|
||||
q3: 'Могу ли я использовать несколько серверов?',
|
||||
a3: 'Да. Вы можете добавить сколько угодно серверов в Настройках и переключаться между ними в любое время. Только один сервер активен одновременно.',
|
||||
s2: 'Воспроизведение',
|
||||
q4: 'Как воспроизводить музыку?',
|
||||
a4: 'Дважды щелкните любой трек для воспроизведения. На страницах альбомов и исполнителей используйте "Воспроизвести все" для запуска всего альбома. Вы также можете перетащить треки в панель очереди.',
|
||||
q5: 'Какие горячие клавиши доступны?',
|
||||
a5: 'Пробел = Воспроизведение / Пауза · Escape = Закрыть полноэкранный плеер. Медиа-клавиши (Воспроизведение/Пауза, Следующий, Предыдущий) работают на всех платформах. На Linux используйте кнопки панели проигрывателя для медиа-клавиш. Внутриприложенные горячие клавиши и системные глобальные горячие клавиши можно настроить в Настройках → Горячие клавиши / Глобальные горячие клавиши.',
|
||||
q6: 'Что такое очередь?',
|
||||
a6: 'Очередь показывает все предстоящие треки. Откройте ее с помощью значка панели в правом верхнем углу заголовка (рядом с индикатором "Сейчас играет"). Вы можете переупорядочивать треки перетаскиванием, перемешивать кнопкой перемешивания и сохранять очередь как плейлист.',
|
||||
q7: 'Как открыть полноэкранный плеер?',
|
||||
a7: 'Нажмите на миниатюру обложки в панели проигрывателя внизу или на значок расширения рядом с ней. Нажмите Escape для закрытия.',
|
||||
q8: 'Как работает повтор?',
|
||||
a8: 'Нажмите кнопку повтора в панели проигрывателя для цикла: Выкл → Повтор всех → Повтор одного.',
|
||||
s3: 'Библиотека',
|
||||
q9: 'Как скачать альбом?',
|
||||
a9: 'Откройте страницу альбома и нажмите "Скачать (ZIP)". Сервер сначала сжимает альбом в ZIP файл — для больших альбомов или файлов без потерь (FLAC / WAV) это может занять некоторое время перед фактическим началом загрузки. Это нормально: прогресс-бар появляется после завершения упаковки сервером и начала передачи.',
|
||||
q10: 'Как добавить треки и альбомы в избранное?',
|
||||
a10: 'Нажмите на значок звезды на любой строке трека или в заголовке альбома. Отмеченные элементы появляются в разделе "Избранное" на боковой панели.',
|
||||
q11: 'Что такое карусель на главной странице?',
|
||||
a11: 'Баннер в верхней части главной страницы случайно выбирает альбомы из вашей библиотеки и вращает их каждые 10 секунд. Нажмите на точки для перехода к конкретному альбому или нажмите на баннер для открытия альбома.',
|
||||
s4: 'Настройки',
|
||||
q12: 'Как изменить тему?',
|
||||
a12: 'Настройки → Тема. Выберите из большого количества тем в 8 группах: Темы Psysonic, Медиаплееры, Операционные системы, Игры, Фильмы, Сериалы, Социальные сети и Классика открытого исходного кода (Catppuccin, Nord, Gruvbox).',
|
||||
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 или yay -S psysonic-bin). Нет AppImage. Если звук отсутствует, убедитесь, что PipeWire или PulseAudio запущены.',
|
||||
q21: 'Приложение показывает черный экран на Linux.',
|
||||
a21: 'Обычно это проблема GPU/EGL драйвера в WebKitGTK. Запустите с 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: 'Альбомы с highest рейтингом',
|
||||
genreDistribution: 'Распределение по жанрам (Топ-20)',
|
||||
loadMore: 'Загрузить еще',
|
||||
statArtists: 'Исполнители',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Песни',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Общее время воспроизведения',
|
||||
genreInsights: 'Обзор по жанрам',
|
||||
formatDistribution: 'Распределение по форматам',
|
||||
formatSample: 'Выборка из {{n}} треков',
|
||||
computing: 'Вычисление…',
|
||||
genreSongs: '{{count}} песен',
|
||||
genreAlbums: '{{count}} альбомов',
|
||||
recentlyAdded: 'Недавно добавленные',
|
||||
decadeDistribution: 'Альбомы по десятилетиям',
|
||||
decadeAlbums_one: '{{count}} альбом',
|
||||
decadeAlbums_few: '{{count}} альбома',
|
||||
decadeAlbums_many: '{{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: 'Текст',
|
||||
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: 'Плейлист обновлен',
|
||||
},
|
||||
radio: {
|
||||
title: 'Интернет-радио',
|
||||
empty: 'Радиостанции не настроены.',
|
||||
addStation: 'Добавить станцию',
|
||||
editStation: 'Редактировать',
|
||||
deleteStation: 'Удалить станцию',
|
||||
confirmDelete: 'Нажмите еще раз для подтверждения',
|
||||
stationName: 'Название станции…',
|
||||
streamUrl: 'URL потока…',
|
||||
homepageUrl: 'URL главной страницы (необязательно)',
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
live: 'ПРЯМОЙ ЭФИР',
|
||||
liveStream: 'Интернет радио',
|
||||
openHomepage: 'Открыть главную страницу',
|
||||
changeCoverLabel: 'Изменить обложку',
|
||||
removeCover: 'Удалить обложку',
|
||||
browseDirectory: 'Поиск в каталоге',
|
||||
directoryPlaceholder: 'Поиск станций…',
|
||||
noResults: 'Станции не найдены.',
|
||||
stationAdded: 'Станция добавлена',
|
||||
filterAll: 'Все',
|
||||
filterFavorites: 'Избранное',
|
||||
sortManual: 'Вручную',
|
||||
sortAZ: 'А → Я',
|
||||
sortZA: 'Я → А',
|
||||
sortNewest: 'Новые',
|
||||
favorite: 'Добавить в избранное',
|
||||
unfavorite: 'Удалить из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
}
|
||||
}
|
||||
+42
-11
@@ -15,14 +15,14 @@ export const zhTranslation = {
|
||||
help: '帮助',
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
updateAvailable: '有可用更新',
|
||||
updateReady: '{{version}} 已就绪',
|
||||
updateLink: '前往发布页面 →',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -324,12 +324,8 @@ export const zhTranslation = {
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已就绪',
|
||||
updaterInstall: '安装并重启',
|
||||
updaterDownloading: '下载中…',
|
||||
updaterInstalling: '安装中…',
|
||||
updaterDownload: '从 GitHub 下载',
|
||||
updaterExperimentalHint: '自动更新功能仍在开发中',
|
||||
updaterVersion: 'v{{version}} 已发布',
|
||||
updaterWebsite: '官网',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -341,7 +337,6 @@ export const zhTranslation = {
|
||||
languageZh: '中文',
|
||||
languageNb: '挪威',
|
||||
languageRu: '俄语',
|
||||
languageRu2: '俄语 2',
|
||||
font: '字体',
|
||||
theme: '主题',
|
||||
appearance: '外观',
|
||||
@@ -401,6 +396,8 @@ export const zhTranslation = {
|
||||
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
|
||||
cacheUsedImages: '图片:',
|
||||
cacheUsedOffline: '离线曲目:',
|
||||
cacheUsedHot: '占用空间:',
|
||||
hotCacheTrackCount: '缓存曲目数:',
|
||||
cacheMaxLabel: '最大容量',
|
||||
cacheClearBtn: '清除缓存',
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
@@ -412,6 +409,22 @@ export const zhTranslation = {
|
||||
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: '显示托盘图标',
|
||||
@@ -420,8 +433,12 @@ export const zhTranslation = {
|
||||
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: '默认下载文件夹',
|
||||
@@ -544,7 +561,7 @@ export const zhTranslation = {
|
||||
a11: '首页顶部的横幅会随机从您的音乐库中选择专辑,每 10 秒轮换一次。点击圆点可跳转到特定专辑,或点击横幅打开专辑。',
|
||||
s4: '设置',
|
||||
q12: '如何更改主题?',
|
||||
a12: '设置 → 主题。从 8 个分组中的大量主题中选择:Psysonic 主题、媒体播放器、操作系统、游戏、电影、电视剧、社交媒体,以及开源经典(Catppuccin、Nord、Gruvbox)。',
|
||||
a12: '设置 → 主题。从 8 个分组中的大量主题中选择:Psysonic 主题、媒体播放器、操作系统、游戏、电影、电视剧、社交媒体,以及开源经典(Catppuccin、Nord、Gruvbox、Nightfox)。',
|
||||
q13: '如何更改语言?',
|
||||
a13: '设置 → 语言。支持英语、德语、法语、荷兰语和中文。',
|
||||
q15: '如何设置下载文件夹?',
|
||||
@@ -685,8 +702,11 @@ export const zhTranslation = {
|
||||
volume: '音量',
|
||||
toggleQueue: '切换队列',
|
||||
lyrics: '歌词',
|
||||
fsLyricsToggle: '全屏歌词',
|
||||
lyricsLoading: '正在加载歌词…',
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
@@ -754,6 +774,17 @@ export const zhTranslation = {
|
||||
removeCover: '删除照片',
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
topArtists: '热门艺术家',
|
||||
topAlbums: '热门专辑',
|
||||
plays: '播放 {{n}} 次',
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
@@ -18,6 +19,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [sort, setSort] = useState<SortType>('alphabeticalByName');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,7 +52,7 @@ export default function Albums() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
|
||||
setLoading(true);
|
||||
@@ -66,7 +68,7 @@ export default function Albums() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
|
||||
+79
-11
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2 } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -12,6 +12,8 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -55,6 +57,10 @@ export default function ArtistDetail() {
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [coverRevision, setCoverRevision] = useState(0);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -64,6 +70,7 @@ export default function ArtistDetail() {
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -120,7 +127,7 @@ export default function ArtistDetail() {
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
}, [artist?.id]);
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
@@ -146,7 +153,7 @@ export default function ArtistDetail() {
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [artist?.id]);
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
@@ -233,6 +240,32 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !artist) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadArtistImage(artist.id, file);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
await invalidateCoverArt(coverId);
|
||||
// Also invalidate with bare artist.id in case coverArt differs
|
||||
if (artist.coverArt && artist.coverArt !== artist.id) {
|
||||
await invalidateCoverArt(artist.id);
|
||||
}
|
||||
setCoverRevision(r => r + 1);
|
||||
showToast(t('artistDetail.uploadImage'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
||||
4000,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -273,7 +306,7 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
@@ -281,6 +314,7 @@ export default function ArtistDetail() {
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
<CachedImage
|
||||
key={coverRevision}
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
@@ -291,6 +325,22 @@ export default function ArtistDetail() {
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className="artist-avatar-upload-overlay"
|
||||
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={22} className="spin-slow" />
|
||||
: <Camera size={22} />}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
@@ -373,11 +423,29 @@ export default function ArtistDetail() {
|
||||
|
||||
{/* Biography — sanitized HTML from server */}
|
||||
{info?.biography && (
|
||||
<div className="artist-bio-section">
|
||||
<div
|
||||
className="artist-bio-text"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<div className="np-info-card artist-bio-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{(info.largeImageUrl || coverId) && (
|
||||
<img
|
||||
src={info.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
||||
alt={artist.name}
|
||||
className="np-artist-thumb"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -86,10 +86,11 @@ export default function Artists() {
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleCount(prev => prev + 50);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
@@ -63,6 +64,7 @@ export default function Favorites() {
|
||||
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAll = async () => {
|
||||
@@ -87,7 +89,7 @@ export default function Favorites() {
|
||||
setLoading(false);
|
||||
};
|
||||
loadAll();
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -17,6 +18,7 @@ export default function GenreDetail() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => {
|
||||
setAlbums([]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Tags, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
function getGenreIcon(name: string): LucideIcon {
|
||||
const n = name.toLowerCase();
|
||||
@@ -59,7 +60,7 @@ export default function Genres() {
|
||||
setGenres(sorted);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion
|
||||
|
||||
// Restore scroll position after genres are rendered
|
||||
useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user