mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
27 Commits
app-v1.34.1
...
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 |
@@ -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
|
||||
|
||||
@@ -5,6 +5,66 @@ 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
|
||||
|
||||
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.1",
|
||||
"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.1
|
||||
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
|
||||
|
||||
@@ -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(', ')}`);
|
||||
Generated
+30
-246
@@ -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"
|
||||
@@ -937,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"
|
||||
@@ -1260,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"
|
||||
@@ -2415,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]]
|
||||
@@ -2573,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"
|
||||
@@ -2845,7 +2805,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2861,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"
|
||||
@@ -2952,12 +2899,6 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2984,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"
|
||||
@@ -3047,7 +2974,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3227,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"
|
||||
@@ -3418,7 +3339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.1"
|
||||
version = "1.34.3"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -3440,8 +3361,8 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -3513,7 +3434,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3662,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"
|
||||
@@ -3788,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",
|
||||
@@ -3940,18 +3847,6 @@ 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"
|
||||
@@ -3962,33 +3857,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -4021,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"
|
||||
@@ -4087,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"
|
||||
@@ -4443,7 +4279,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4566,6 +4402,7 @@ dependencies = [
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-alac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
@@ -4620,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"
|
||||
@@ -4657,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",
|
||||
@@ -4817,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"
|
||||
@@ -5083,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"
|
||||
@@ -5295,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"
|
||||
@@ -6031,15 +5846,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
@@ -6828,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"
|
||||
@@ -7084,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"
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.1"
|
||||
version = "1.34.3"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -31,7 +31,7 @@ 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"] }
|
||||
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"
|
||||
@@ -39,8 +39,16 @@ 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
|
||||
}
|
||||
}
|
||||
+311
-38
@@ -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();
|
||||
|
||||
@@ -1350,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>,
|
||||
@@ -1411,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() {
|
||||
@@ -1437,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,
|
||||
@@ -1605,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> {
|
||||
@@ -1743,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;
|
||||
@@ -1757,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 {
|
||||
@@ -1783,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();
|
||||
@@ -1869,6 +2113,7 @@ 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 track → nothing to do.
|
||||
@@ -1950,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;
|
||||
@@ -1959,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();
|
||||
@@ -2366,6 +2631,14 @@ pub async fn audio_preload(
|
||||
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 {
|
||||
@@ -2498,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);
|
||||
|
||||
|
||||
+54
-25
@@ -218,35 +218,45 @@ 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.
|
||||
/// - `cover_art_url`: optional direct URL to album artwork. If None, tries to
|
||||
/// fetch from iTunes Search API using artist + album.
|
||||
/// - `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 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> {
|
||||
// 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 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 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
|
||||
};
|
||||
@@ -280,21 +290,40 @@ pub async fn discord_update_presence(
|
||||
.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() {
|
||||
|
||||
+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.1",
|
||||
"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();
|
||||
}, []);
|
||||
|
||||
+53
-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 ?? [] };
|
||||
|
||||
+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
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
||||
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';
|
||||
@@ -21,7 +22,7 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
|
||||
// Slot height = 3.6vh = window.innerHeight * 0.036 — must match CSS height: 3.6vh.
|
||||
// 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)
|
||||
@@ -49,9 +50,9 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
|
||||
const slotH = useRef(window.innerHeight * 0.036);
|
||||
const slotH = useRef(window.innerHeight * 0.06);
|
||||
useEffect(() => {
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.036; };
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
@@ -284,6 +285,25 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// `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(() => {
|
||||
@@ -362,6 +382,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
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) */}
|
||||
@@ -392,12 +413,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<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">
|
||||
@@ -445,7 +466,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? 'var(--accent)' : 'rgba(255,255,255,0.35)' }}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+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}
|
||||
|
||||
@@ -46,6 +46,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ 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' },
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
}
|
||||
+36
-9
@@ -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',
|
||||
@@ -404,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.',
|
||||
@@ -415,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',
|
||||
@@ -423,6 +437,8 @@ 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',
|
||||
@@ -762,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',
|
||||
|
||||
+37
-9
@@ -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',
|
||||
@@ -404,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.',
|
||||
@@ -415,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',
|
||||
@@ -423,6 +438,8 @@ 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',
|
||||
@@ -762,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',
|
||||
|
||||
+36
-9
@@ -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',
|
||||
@@ -404,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.',
|
||||
@@ -415,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',
|
||||
@@ -423,6 +437,8 @@ 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',
|
||||
@@ -762,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',
|
||||
|
||||
+36
-9
@@ -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',
|
||||
@@ -405,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.',
|
||||
@@ -416,12 +414,30 @@ 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',
|
||||
@@ -761,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',
|
||||
|
||||
+36
-9
@@ -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',
|
||||
@@ -404,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.',
|
||||
@@ -415,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',
|
||||
@@ -423,6 +437,8 @@ 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',
|
||||
@@ -762,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',
|
||||
|
||||
+82
-59
@@ -1,8 +1,8 @@
|
||||
/** Russian UI strings — natural phrasing for a music desktop app */
|
||||
export const ruTranslation = {
|
||||
sidebar: {
|
||||
library: 'Библиотека',
|
||||
mainstage: 'Главная',
|
||||
library: 'Медиатека',
|
||||
mainstage: 'Для вас',
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
@@ -16,18 +16,18 @@ export const ruTranslation = {
|
||||
help: 'Справка',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
updateReady: '{{version}} готово',
|
||||
updateLink: 'Перейти к релизу →',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
},
|
||||
home: {
|
||||
hero: 'Рекомендации',
|
||||
starred: 'Избранное',
|
||||
hero: 'Подборка',
|
||||
starred: 'Личное избранное',
|
||||
recent: 'Недавно добавлено',
|
||||
mostPlayed: 'Чаще всего',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
@@ -38,14 +38,14 @@ export const ruTranslation = {
|
||||
discoverArtistsMore: 'Все исполнители',
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Рекомендуемый альбом',
|
||||
eyebrow: 'Альбом дня',
|
||||
playAlbum: 'Воспроизвести альбом',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Исполнитель, альбом или трек…',
|
||||
noResults: 'Нет результатов для «{{query}}»',
|
||||
noResults: 'Ничего не найдено по запросу «{{query}}»',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
@@ -93,11 +93,11 @@ export const ruTranslation = {
|
||||
addToQueue: 'В конец очереди',
|
||||
enqueueAlbum: 'Альбом в очередь',
|
||||
startRadio: 'Радио по похожим',
|
||||
lfmLove: 'Нравится на Last.fm',
|
||||
lfmUnlove: 'Убрать лайк с Last.fm',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
favoriteArtist: 'Добавить исполнителя в избранное',
|
||||
favoriteAlbum: 'Добавить альбом в избранное',
|
||||
favoriteArtist: 'Исполнитель в избранное',
|
||||
favoriteAlbum: 'Альбом в избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
unfavoriteArtist: 'Убрать исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Убрать альбом из избранного',
|
||||
@@ -121,7 +121,7 @@ export const ruTranslation = {
|
||||
offlineDownloading: 'Кэширование… ({{n}} из {{total}})',
|
||||
removeOffline: 'Удалить офлайн-копию',
|
||||
offlineStorageFull:
|
||||
'Место для офлайн-кэша закончилось (лимит {{mb}} МБ). Освободите место или увеличьте лимит в настройках.',
|
||||
'Место для офлайн закончилось (лимит {{mb}} МБ). Освободите место в офлайн-библиотеке или увеличьте лимит в настройках.',
|
||||
offlineStorageGoToLibrary: 'Офлайн-библиотека',
|
||||
offlineStorageGoToSettings: 'Настройки',
|
||||
favoriteAdd: 'В избранное',
|
||||
@@ -129,7 +129,7 @@ export const ruTranslation = {
|
||||
favorite: 'Избранное',
|
||||
noBio: 'Биография недоступна.',
|
||||
moreByArtist: 'Ещё от {{artist}}',
|
||||
tracksCount: '{{n}} треков',
|
||||
tracksCount: 'Композиций: {{n}}',
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
@@ -140,7 +140,7 @@ export const ruTranslation = {
|
||||
trackRating: 'Оценка',
|
||||
trackDuration: 'Длительность',
|
||||
trackTotal: 'Итого',
|
||||
columns: 'Столбцы',
|
||||
columns: 'Колонки',
|
||||
notFound: 'Альбом не найден.',
|
||||
bioModal: 'Биография исполнителя',
|
||||
bioClose: 'Закрыть',
|
||||
@@ -174,18 +174,18 @@ export const ruTranslation = {
|
||||
featuredOn: 'Также участвует в',
|
||||
similarArtists: 'Похожие исполнители',
|
||||
cacheOffline: 'Сохранить дискографию офлайн',
|
||||
offlineCached: 'Дискография сохранена в кэш',
|
||||
offlineCached: 'Дискография сохранена',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
uploadImage: 'Загрузить фото исполнителя',
|
||||
uploadImageError: 'Не удалось загрузить изображение',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Избранное',
|
||||
empty: 'Здесь пока пусто... \nПора добавить что-нибудь в избранное.',
|
||||
empty: 'Пока ничего не отмечено звёздочкой.',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
enqueueAll: 'Добавить всё в очередь',
|
||||
enqueueAll: 'Всё в очередь',
|
||||
playAll: 'Воспроизвести всё',
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
@@ -204,7 +204,7 @@ export const ruTranslation = {
|
||||
loading: 'Загрузка жанров…',
|
||||
empty: 'Жанры не найдены.',
|
||||
albumsLoading: 'Загрузка альбомов…',
|
||||
albumsEmpty: 'Альбомы в этом жанре не найдены.',
|
||||
albumsEmpty: 'В этом жанре альбомов нет.',
|
||||
loadMore: 'Ещё',
|
||||
back: 'Назад',
|
||||
},
|
||||
@@ -220,7 +220,7 @@ export const ruTranslation = {
|
||||
trackDuration: 'Длительность',
|
||||
favoriteAdd: 'В избранное',
|
||||
favoriteRemove: 'Убрать из избранного',
|
||||
play: 'Воспроизвести',
|
||||
play: 'Играть',
|
||||
trackGenre: 'Жанр',
|
||||
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
|
||||
excludeAudiobooksDesc:
|
||||
@@ -275,18 +275,18 @@ export const ruTranslation = {
|
||||
serverNamePlaceholder: 'Мой Navidrome',
|
||||
serverUrl: 'Адрес сервера',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
|
||||
username: 'Имя пользователя',
|
||||
username: 'Логин',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Пароль',
|
||||
showPassword: 'Показать пароль',
|
||||
hidePassword: 'Скрыть пароль',
|
||||
connect: 'Подключиться',
|
||||
connecting: 'Подключение…',
|
||||
connected: 'Подключено!',
|
||||
connected: 'Готово!',
|
||||
error: 'Не удалось подключиться — проверьте данные.',
|
||||
urlRequired: 'Укажите адрес сервера.',
|
||||
savedServers: 'Сохранённые серверы',
|
||||
addNew: 'Или добавьте новый сервер',
|
||||
addNew: 'Или добавить новый сервер',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Подключено',
|
||||
@@ -297,7 +297,7 @@ export const ruTranslation = {
|
||||
extern: 'Внешний',
|
||||
offlineTitle: 'Нет связи с сервером',
|
||||
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
|
||||
offlineModeBanner: 'Воспроизведение из локального кэша',
|
||||
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
@@ -342,12 +342,8 @@ export const ruTranslation = {
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} готова к установке',
|
||||
updaterInstall: 'Установить и перезапустить',
|
||||
updaterDownloading: 'Скачивание…',
|
||||
updaterInstalling: 'Установка…',
|
||||
updaterDownload: 'Скачать с GitHub',
|
||||
updaterExperimentalHint: 'Автообновление в разработке',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -359,6 +355,7 @@ export const ruTranslation = {
|
||||
languageZh: 'Китайский',
|
||||
languageNb: 'Норвежский',
|
||||
languageRu: 'Русский',
|
||||
languageRu2: 'Русский 2',
|
||||
font: 'Шрифт',
|
||||
theme: 'Тема',
|
||||
appearance: 'Оформление',
|
||||
@@ -379,14 +376,14 @@ export const ruTranslation = {
|
||||
serverFailed: 'Ошибка подключения.',
|
||||
testBtn: 'Проверить',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Совместимо с: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Подключено',
|
||||
failed: 'Ошибка',
|
||||
eqTitle: 'Эквалайзер',
|
||||
eqEnabled: 'Включить эквалайзер',
|
||||
eqPreset: 'Пресет',
|
||||
eqPresetCustom: 'Свой',
|
||||
eqPresetBuiltin: 'Встроенные пресеты',
|
||||
eqPresetBuiltin: 'Встроенные',
|
||||
eqPresetCustomGroup: 'Мои пресеты',
|
||||
eqSavePreset: 'Сохранить как пресет',
|
||||
eqPresetName: 'Название пресета…',
|
||||
@@ -408,21 +405,23 @@ export const ruTranslation = {
|
||||
lfmConnected: 'Аккаунт',
|
||||
lfmDisconnect: 'Отключить',
|
||||
lfmConnectDesc:
|
||||
'Подключите аккаунт Last.fm для включения скробблинга и обновления "Сейчас играет" напрямую из Psysonic — без необходимости настройки Navidrome.',
|
||||
'Привяжите Last.fm для скробблинга и статуса «Сейчас слушаю» прямо из Psysonic — настраивать Navidrome не нужно.',
|
||||
lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.',
|
||||
lfmScrobbles: '{{n}} скробблов',
|
||||
lfmMemberSince: 'Участник с {{year}} года',
|
||||
lfmScrobbles: 'Скробблов: {{n}}',
|
||||
lfmMemberSince: 'С {{year}} года',
|
||||
scrobbleEnabled: 'Скробблинг включён',
|
||||
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% воспроизведения',
|
||||
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% трека',
|
||||
behavior: 'Поведение',
|
||||
cacheTitle: 'Макс. размер кэша',
|
||||
cacheDesc:
|
||||
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Сохраненные альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
|
||||
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Офлайн-альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
|
||||
cacheUsedImages: 'Изображения:',
|
||||
cacheUsedOffline: 'Сохраненные треки:',
|
||||
cacheUsedOffline: 'Офлайн-треки:',
|
||||
cacheUsedHot: 'На диске:',
|
||||
hotCacheTrackCount: 'Треков в кэше:',
|
||||
cacheMaxLabel: 'Лимит',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
cacheClearWarning: 'Будут удалены и все сохраненные альбомы.',
|
||||
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
||||
cacheClearConfirm: 'Очистить всё',
|
||||
cacheClearCancel: 'Отмена',
|
||||
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
|
||||
@@ -431,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:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
@@ -438,16 +453,16 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
discordRichPresence: 'Статус в Discord (Rich Presence)',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord (Rich Presence). Нужен запущенный клиент Discord.',
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
lyricsServerFirst: 'Приоритет серверного текста',
|
||||
lyricsServerFirstDesc: 'Сначала запрашивать текст с сервера (теги, sidecar-файлы), затем LRCLIB. Отключите, чтобы использовать LRCLIB первым.',
|
||||
downloadsTitle: 'Экспорт в ZIP и архивирование',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в виде ZIP на диск.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
pickFolderTitle: 'Папка для загрузок',
|
||||
@@ -455,9 +470,9 @@ export const ruTranslation = {
|
||||
logout: 'Выйти',
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc:
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). Построен на Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain (выравнивание громкости), жанры и много тем оформления.',
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
|
||||
aboutFeatures:
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain (выравнивание громкости) · жанры · десятки тем · несколько языков',
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
|
||||
aboutLicense: 'Лицензия',
|
||||
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
|
||||
aboutRepo: 'Исходный код на GitHub',
|
||||
@@ -534,7 +549,7 @@ export const ruTranslation = {
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
preloadEarly: 'Рано (через 5 с после старта)',
|
||||
preloadCustom: 'Свой интервал',
|
||||
preloadCustomSeconds: '{{n}} секунд до конца',
|
||||
preloadCustomSeconds: 'Секунд до конца: {{n}}',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
@@ -590,7 +605,7 @@ export const ruTranslation = {
|
||||
s5: 'Скробблинг',
|
||||
q16: 'Как устроен скробблинг?',
|
||||
a16:
|
||||
'Psysonic отправляет прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
|
||||
'Psysonic шлёт прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
|
||||
q17: 'Когда уходит скроббл?',
|
||||
a17: 'После прослушивания примерно половины трека.',
|
||||
q22: 'Что за волна внизу?',
|
||||
@@ -613,13 +628,13 @@ export const ruTranslation = {
|
||||
s6: 'Если что-то не так',
|
||||
q18: 'Медленно грузятся обложки.',
|
||||
a18:
|
||||
'Первый раз картинки загружаются с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
|
||||
'Первый раз картинки тянутся с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
|
||||
q19: 'Тест соединения не проходит.',
|
||||
a19:
|
||||
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локальной сети иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
|
||||
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локалки иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
|
||||
q20: 'Нет звука в Linux.',
|
||||
a20:
|
||||
'Убедитесь, что запущены PipeWire или PulseAudio.',
|
||||
'Есть пакеты .deb, .rpm и AUR. Убедитесь, что запущены PipeWire или PulseAudio.',
|
||||
q21: 'Чёрный экран в Linux.',
|
||||
a21:
|
||||
'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.',
|
||||
@@ -640,7 +655,7 @@ export const ruTranslation = {
|
||||
q35: 'Как сохранить альбом офлайн?',
|
||||
a35:
|
||||
'Страница альбома → иконка загрузки в шапке. Прогресс на кнопке, после загрузки — индикатор готовности. Список — в офлайн-библиотеке.',
|
||||
q36: 'Сколько места занимает кэш?',
|
||||
q36: 'Сколько места занимает офлайн?',
|
||||
a36:
|
||||
'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.',
|
||||
},
|
||||
@@ -748,18 +763,15 @@ export const ruTranslation = {
|
||||
volume: 'Громкость',
|
||||
toggleQueue: 'Очередь',
|
||||
lyrics: 'Текст',
|
||||
fsLyricsToggle: 'Текст в полноэкранном режиме',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceServer: 'Источник: сервер',
|
||||
lyricsSourceLrclib: 'Источник: LRCLIB',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
songTitle: 'Название',
|
||||
artist: 'Исполнитель',
|
||||
album: 'Альбом',
|
||||
albumArtist: 'Исполнитель альбома',
|
||||
albumArtist: 'Альбомный исполнитель',
|
||||
year: 'Год',
|
||||
genre: 'Жанр',
|
||||
duration: 'Длительность',
|
||||
@@ -803,7 +815,7 @@ export const ruTranslation = {
|
||||
noSuggestions: 'Пока нет рекомендаций.',
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'Добавить в плейлист',
|
||||
addSong: 'В плейлист',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
@@ -820,6 +832,17 @@ export const ruTranslation = {
|
||||
removeCover: 'Убрать фото',
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист сохранён',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
|
||||
+36
-9
@@ -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: '设置',
|
||||
@@ -400,6 +396,8 @@ export const zhTranslation = {
|
||||
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
|
||||
cacheUsedImages: '图片:',
|
||||
cacheUsedOffline: '离线曲目:',
|
||||
cacheUsedHot: '占用空间:',
|
||||
hotCacheTrackCount: '缓存曲目数:',
|
||||
cacheMaxLabel: '最大容量',
|
||||
cacheClearBtn: '清除缓存',
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
@@ -411,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: '显示托盘图标',
|
||||
@@ -419,6 +433,8 @@ export const zhTranslation = {
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
|
||||
discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
@@ -758,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);
|
||||
|
||||
@@ -70,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;
|
||||
@@ -126,7 +127,7 @@ export default function ArtistDetail() {
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
}, [artist?.id]);
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
@@ -152,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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+3
-1
@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -44,7 +46,7 @@ export default function Home() {
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion, homeSections]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
|
||||
@@ -423,7 +423,7 @@ function RadioCard({
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={[
|
||||
'album-card',
|
||||
'album-card radio-card',
|
||||
isActive ? 'radio-card-active' : '',
|
||||
dropIndicator === 'before' ? 'radio-card-drop-before' : '',
|
||||
dropIndicator === 'after' ? 'radio-card-drop-after' : '',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronLeft } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { search, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function LabelAlbums() {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,6 +12,7 @@ export default function LabelAlbums() {
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
@@ -31,7 +33,7 @@ export default function LabelAlbums() {
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
}, [name, musicLibraryFilterVersion]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp } from 'lucide-react';
|
||||
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface ArtistEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
coverArt?: string;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
function deriveTopArtists(albums: SubsonicAlbum[]): ArtistEntry[] {
|
||||
const map = new Map<string, ArtistEntry>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (plays === 0) continue;
|
||||
const entry = map.get(a.artistId);
|
||||
if (entry) {
|
||||
entry.totalPlays += plays;
|
||||
if (!entry.coverArt && a.coverArt) entry.coverArt = a.coverArt;
|
||||
} else {
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, coverArt: a.coverArt, totalPlays: plays });
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').useTranslation>['t']): string {
|
||||
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
|
||||
}
|
||||
|
||||
export default function MostPlayed() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [sortAsc, setSortAsc] = useState(false); // false = most plays first
|
||||
|
||||
const topArtists = deriveTopArtists(albums).slice(0, 10);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setAlbums([]);
|
||||
setHasMore(true);
|
||||
try {
|
||||
const result = await getAlbumList('frequent', PAGE_SIZE, 0);
|
||||
setAlbums(result);
|
||||
setHasMore(result.length === PAGE_SIZE);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await getAlbumList('frequent', PAGE_SIZE, albums.length);
|
||||
setAlbums(prev => [...prev, ...result]);
|
||||
setHasMore(result.length === PAGE_SIZE);
|
||||
} catch {}
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const sorted = sortAsc ? [...albums].reverse() : albums;
|
||||
const withPlays = sorted.filter(a => (a.playCount ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="mp-header">
|
||||
<div className="mp-header-left">
|
||||
<TrendingUp size={22} className="mp-header-icon" />
|
||||
<h1 className="mp-title">{t('mostPlayed.title')}</h1>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost mp-sort-btn"
|
||||
onClick={() => setSortAsc(v => !v)}
|
||||
data-tooltip={sortAsc ? t('mostPlayed.sortMost') : t('mostPlayed.sortLeast')}
|
||||
>
|
||||
{sortAsc ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||
{sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}
|
||||
<ArrowUpDown size={12} style={{ opacity: 0.45 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Top Artists ── */}
|
||||
{!loading && topArtists.length > 0 && (
|
||||
<section className="mp-section">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||
<div className="mp-artist-grid">
|
||||
{topArtists.map((artist, i) => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="mp-artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<span className="mp-rank">{i + 1}</span>
|
||||
{artist.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 80)}
|
||||
alt=""
|
||||
className="mp-artist-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
|
||||
)}
|
||||
<div className="mp-artist-info">
|
||||
<span className="mp-artist-name truncate">{artist.name}</span>
|
||||
<span className="mp-artist-plays">{formatPlays(artist.totalPlays, t)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Top Albums ── */}
|
||||
<section className="mp-section">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topAlbums')}</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="mp-loading"><div className="spinner" /></div>
|
||||
) : withPlays.length === 0 ? (
|
||||
<div className="empty-state">{t('mostPlayed.noData')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mp-album-list">
|
||||
{withPlays.map((album, i) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="mp-album-row"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
onContextMenu={e => { e.preventDefault(); playAlbum(album.id); }}
|
||||
>
|
||||
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
|
||||
{album.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(album.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(album.coverArt, 80)}
|
||||
alt=""
|
||||
className="mp-album-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="mp-album-cover mp-album-cover--placeholder" />
|
||||
)}
|
||||
<div className="mp-album-meta">
|
||||
<span className="mp-album-name truncate">{album.name}</span>
|
||||
<span
|
||||
className="mp-album-artist truncate track-artist-link"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
||||
>
|
||||
{album.artist}
|
||||
</span>
|
||||
</div>
|
||||
{album.year && <span className="mp-album-year">{album.year}</span>}
|
||||
<span className="mp-album-plays">{(album.playCount ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
className="btn btn-ghost mp-load-more"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} /> : null}
|
||||
{t('mostPlayed.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
@@ -15,6 +16,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -43,7 +45,7 @@ export default function NewReleases() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filtered) loadFiltered(selectedGenres);
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
@@ -89,6 +100,9 @@ export default function PlaylistDetail() {
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
@@ -101,6 +115,7 @@ export default function PlaylistDetail() {
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -294,6 +309,46 @@ export default function PlaylistDetail() {
|
||||
setEditingMeta(false);
|
||||
};
|
||||
|
||||
// ── ZIP Download ──────────────────────────────────────────────
|
||||
const handleDownload = async () => {
|
||||
if (!playlist || !id) return;
|
||||
const folder = downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(id);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
if (total && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setDownloadProgress(Math.round((received / total) * 100));
|
||||
}
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer() as ArrayBuffer;
|
||||
chunks.push(new Uint8Array(buffer));
|
||||
setDownloadProgress(100);
|
||||
}
|
||||
const blob = new Blob(chunks);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
@@ -565,6 +620,21 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{songs.length > 0 && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
|
||||
<Download size={16} /> {t('playlists.downloadZip')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
@@ -21,6 +22,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
@@ -42,7 +44,7 @@ export default function RandomAlbums() {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function RandomMix() {
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||
|
||||
@@ -82,7 +83,7 @@ export default function RandomMix() {
|
||||
setAllAvailableGenres(available);
|
||||
setDisplayedGenres(available.slice(0, 20));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
const filteredSongs = songs.filter(song => {
|
||||
if (!excludeAudiobooks) return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user