Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3b9ebe00 | |||
| d4b18fec5a | |||
| dbb53bfa70 | |||
| 737b057d4a | |||
| 0c2ac13fed | |||
| 1624880c2a | |||
| 6587c82e0c | |||
| 8a1d942128 | |||
| 829936a78d | |||
| ada7dd010f | |||
| 67de94d955 | |||
| 705c80ef07 | |||
| 504c53e71d | |||
| 5be7f7cabd | |||
| c1624342d3 | |||
| 4182b67732 | |||
| 7b4eeea0dc | |||
| 47cea7e3d6 | |||
| 5ed1b58d67 | |||
| 900853fedc | |||
| 2c3c89f078 | |||
| 45fa606ae1 | |||
| d49137475f | |||
| dd4e2f1162 | |||
| 78beb699a7 | |||
| 6f63d7020c | |||
| a606d1edd6 | |||
| 9ef8566d64 | |||
| 4b8b6ae797 | |||
| 9319c40fde | |||
| 63a3bcd0f4 | |||
| a4c400cc69 | |||
| e4aad22c03 |
@@ -19,12 +19,7 @@ jobs:
|
||||
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-
|
||||
cache: 'npm'
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -97,12 +92,7 @@ jobs:
|
||||
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-
|
||||
cache: 'npm'
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -141,13 +131,7 @@ jobs:
|
||||
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-
|
||||
cache: 'npm'
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -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.4] - 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (1–5 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
|
||||
|
||||
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
|
||||
|
||||
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating when a song is manually skipped before a configurable playback threshold (default: 20 s). Can be enabled and adjusted in Settings → Ratings.
|
||||
|
||||
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
|
||||
|
||||
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
|
||||
|
||||
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
|
||||
|
||||
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
|
||||
|
||||
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
|
||||
|
||||
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
|
||||
|
||||
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
|
||||
|
||||
### i18n
|
||||
|
||||
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -28,7 +28,7 @@ Psysonic is a beautiful desktop music player built completely from the ground up
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.4",
|
||||
"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
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 365 KiB |
@@ -3339,7 +3339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.1"
|
||||
version = "1.34.4"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -4504,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.2"
|
||||
version = "1.34.4"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -44,3 +44,11 @@ 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" }
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
|
||||
@@ -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","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-minimize","core:window:allow-toggle-maximize","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"]}}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -923,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 {
|
||||
@@ -941,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
|
||||
@@ -952,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 {
|
||||
@@ -969,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}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1201,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 == ' ')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -36,6 +36,19 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
#[tauri::command]
|
||||
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
let _ = win.set_decorations(enabled);
|
||||
// Re-enabling native decorations on GTK causes the window manager to
|
||||
// re-stack the window, which drops focus. Bring it back immediately.
|
||||
if enabled {
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 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> {
|
||||
@@ -826,6 +839,17 @@ pub fn run() {
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// ── Custom title bar on Linux ─────────────────────────────────
|
||||
// Remove OS window decorations so the React TitleBar component
|
||||
// takes over. macOS and Windows keep their native decorations.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.set_decorations(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
// Always build on startup; the frontend calls toggle_tray_icon(false)
|
||||
// immediately after load if the user has disabled the tray icon.
|
||||
@@ -951,6 +975,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
mpris_set_metadata,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.4",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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';
|
||||
@@ -50,10 +51,12 @@ import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders } from './api/subsonic';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
@@ -72,6 +75,17 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
const win = getCurrentWindow();
|
||||
let unlisten: (() => void) | undefined;
|
||||
win.onResized(() => {
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
@@ -86,24 +100,45 @@ function AppShell() {
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {});
|
||||
}, [useCustomTitlebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
if (!cancelled) setMusicFolders(folders);
|
||||
if (stillThisServer()) setMusicFolders(folders);
|
||||
} catch {
|
||||
if (!cancelled) setMusicFolders([]);
|
||||
if (stillThisServer()) setMusicFolders([]);
|
||||
}
|
||||
try {
|
||||
const level = await probeEntityRatingSupport();
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
|
||||
} catch {
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders]);
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
|
||||
|
||||
// Reset scroll position on route change
|
||||
useEffect(() => {
|
||||
document.querySelector('.content-body')?.scrollTo({ top: 0 });
|
||||
}, [location.pathname]);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
@@ -247,16 +282,18 @@ function AppShell() {
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="app-shell"
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && <TitleBar />}
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
@@ -304,6 +341,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 />} />
|
||||
|
||||
@@ -58,11 +58,21 @@ export interface SubsonicAlbum {
|
||||
coverArt?: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
playCount?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
export interface SubsonicOpenArtistRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -78,6 +88,11 @@ export interface SubsonicSong {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
userRating?: number;
|
||||
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||
albumUserRating?: number;
|
||||
artistUserRating?: number;
|
||||
artists?: SubsonicOpenArtistRef[];
|
||||
albumArtists?: SubsonicOpenArtistRef[];
|
||||
// Audio technical info
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
@@ -140,6 +155,8 @@ export interface SubsonicArtist {
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
@@ -252,6 +269,71 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
|
||||
function parseEntityUserRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||
export async function prefetchArtistUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseEntityUserRating(artist.userRating);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
||||
export async function prefetchAlbumUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseEntityUserRating(album.userRating);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
@@ -373,6 +455,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
|
||||
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
|
||||
export type EntityRatingSupportLevel = 'track_only' | 'full';
|
||||
|
||||
/**
|
||||
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
|
||||
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
|
||||
*/
|
||||
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
|
||||
try {
|
||||
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
8000,
|
||||
);
|
||||
if (data.openSubsonic === true) return 'full';
|
||||
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
|
||||
return 'track_only';
|
||||
} catch {
|
||||
return 'track_only';
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrobbleSong(id: string, time: number): Promise<void> {
|
||||
try {
|
||||
await api('scrobble.view', { id, time, submission: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, HardDriveDownload } from 'lucide-react';
|
||||
import { Play, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -11,9 +11,13 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
function AlbumCard({ album }: AlbumCardProps) {
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -25,20 +29,26 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id); return; }
|
||||
navigate(`/album/${album.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="album-card card"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
if (selectionMode) { e.preventDefault(); return; }
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
if (selectionMode || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
@@ -64,20 +74,27 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{isOffline && (
|
||||
{isOffline && !selectionMode && (
|
||||
<div className="album-card-offline-badge" aria-label="Offline available">
|
||||
<HardDriveDownload size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
|
||||
{selected && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
{!selectionMode && (
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
@@ -87,6 +104,13 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||
>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
{showRating && (album.userRating ?? 0) > 0 && (
|
||||
<div className="album-card-rating-row">
|
||||
<span className="album-card-rating-stars">
|
||||
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -85,6 +87,10 @@ interface AlbumHeaderProps {
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -107,6 +113,9 @@ export default function AlbumHeader({
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -186,6 +195,15 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-entity-rating">
|
||||
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="album-detail-actions-mobile">
|
||||
{/* Row 1 — Primary actions */}
|
||||
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -24,29 +25,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
@@ -58,14 +36,14 @@ const COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
// Columns where cell content should be centred (both header and rows)
|
||||
// Columns where header label is centred in the cell (matches row controls below)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
@@ -76,6 +54,8 @@ interface AlbumTrackListProps {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
ratings: Record<string, number>;
|
||||
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
@@ -89,6 +69,7 @@ export default function AlbumTrackList({
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
ratings,
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
@@ -193,13 +174,21 @@ export default function AlbumTrackList({
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
|
||||
// direction=1: drag right → this column grows, title (1fr) shrinks
|
||||
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
@@ -266,7 +255,7 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -381,6 +382,13 @@ export default function ContextMenu() {
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||
<StarRating
|
||||
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -501,6 +509,13 @@ export default function ContextMenu() {
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||
<StarRating
|
||||
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
@@ -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) */}
|
||||
@@ -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>
|
||||
|
||||
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||
const HERO_RANDOM_POOL = 32;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
@@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, [albumsProp, musicLibraryFilterVersion]);
|
||||
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
|
||||
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
|
||||
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
|
||||
getRandomAlbums(pool)
|
||||
.then(async raw => {
|
||||
const list = albumMix
|
||||
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
|
||||
: raw;
|
||||
setAlbums(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [
|
||||
albumsProp,
|
||||
musicLibraryFilterVersion,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
@@ -37,6 +38,7 @@ export default function PlayerBar() {
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
userRatingOverrides, setUserRatingOverride,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
@@ -138,6 +140,14 @@ export default function PlayerBar() {
|
||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
{currentTrack && !isRadio && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||
className="player-track-rating"
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
|
||||
@@ -214,6 +214,7 @@ export default function QueuePanel() {
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
@@ -447,7 +448,7 @@ export default function QueuePanel() {
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2,
|
||||
ChevronDown, Check, Music2, TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -24,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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
maxStars = 5,
|
||||
maxSelectable: maxSelectableProp,
|
||||
labelKey = 'albumDetail.ratingLabel',
|
||||
ariaLabel,
|
||||
className = '',
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (rating: number) => void;
|
||||
disabled?: boolean;
|
||||
/** Number of star buttons (1…maxStars). Default 5. */
|
||||
maxStars?: number;
|
||||
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
|
||||
maxSelectable?: number;
|
||||
labelKey?: string;
|
||||
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stars = React.useMemo(
|
||||
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
|
||||
[maxStars]
|
||||
);
|
||||
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
|
||||
const [hover, setHover] = React.useState(0);
|
||||
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
|
||||
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
|
||||
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
|
||||
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
|
||||
|
||||
const cappedValue = Math.min(Math.max(0, value), selectCap);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value > 0) setSuppressHoverPreview(false);
|
||||
}, [value]);
|
||||
|
||||
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
|
||||
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
|
||||
|
||||
const handleStarClick = (n: number) => {
|
||||
if (disabled || n > selectCap) return;
|
||||
setSuppressHoverPreview(false);
|
||||
|
||||
const next = cappedValue === n ? 0 : n;
|
||||
onChange(next);
|
||||
setHover(0);
|
||||
|
||||
setPulseStar(null);
|
||||
setClearShrinkStar(null);
|
||||
|
||||
if (next === 0) {
|
||||
setSuppressHoverPreview(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setClearShrinkStar(n));
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setPulseStar(n));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerLeave = () => {
|
||||
setHover(0);
|
||||
setSuppressHoverPreview(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? t(labelKey)}
|
||||
aria-disabled={disabled}
|
||||
onMouseLeave={disabled ? undefined : handleContainerLeave}
|
||||
>
|
||||
{stars.map(n => {
|
||||
const locked = n > selectCap;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
|
||||
onMouseEnter={() =>
|
||||
!disabled && !suppressHoverPreview && !locked && setHover(n)
|
||||
}
|
||||
onClick={() => handleStarClick(n)}
|
||||
onAnimationEnd={e => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
const name = e.animationName;
|
||||
if (name === 'star-rating-star-pulse') {
|
||||
setPulseStar(s => (s === n ? null : s));
|
||||
}
|
||||
if (name === 'star-rating-star-clear-shrink') {
|
||||
setClearShrinkStar(s => (s === n ? null : s));
|
||||
}
|
||||
}}
|
||||
disabled={disabled || locked}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={filled(n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { X, Minus, Square } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
export default function TitleBar() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
return (
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
|
||||
|
||||
<div className="titlebar-track" data-tauri-drag-region>
|
||||
{currentTrack && (
|
||||
<>
|
||||
<span className="titlebar-track-state">{isPlaying ? '▶' : '⏸'}</span>
|
||||
<span className="titlebar-track-text truncate">
|
||||
{currentTrack.artist && `${currentTrack.artist} – `}{currentTrack.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="titlebar-controls">
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-minimize"
|
||||
onClick={() => win.minimize()}
|
||||
data-tooltip="Minimize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-maximize"
|
||||
onClick={() => win.toggleMaximize()}
|
||||
data-tooltip="Maximize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Square size={9} />
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-close"
|
||||
onClick={() => win.close()}
|
||||
data-tooltip="Close"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
|
||||
|
||||
function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
@@ -7,6 +8,65 @@ function fmt(s: number): string {
|
||||
}
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
const SEG_COUNT = 60;
|
||||
|
||||
// ── animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
life: number; maxLife: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AnimState = {
|
||||
particles: Particle[];
|
||||
time: number;
|
||||
lastProgress: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
export function makeAnimState(): AnimState {
|
||||
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
|
||||
}
|
||||
|
||||
const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
|
||||
|
||||
// ── color helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getColors() {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
|
||||
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
|
||||
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
|
||||
};
|
||||
}
|
||||
|
||||
// ── canvas setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
function setupCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
return { ctx, w, h };
|
||||
}
|
||||
|
||||
// ── waveform heights ──────────────────────────────────────────────────────────
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
@@ -17,125 +77,725 @@ function hashStr(str: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeHeights(trackId: string): Float32Array {
|
||||
export function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
// Smooth for an organic look
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
// Normalize to [0.12, 1.0]
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
}
|
||||
if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── draw functions ────────────────────────────────────────────────────────────
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
// Pass 1 — unplayed (dim)
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillStyle = unplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
// Pass 2 — buffered (slightly brighter)
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = colorBuffered;
|
||||
ctx.fillStyle = buffCol;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
// Pass 3 — played (accent color + glow)
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = colorAccent;
|
||||
ctx.shadowColor = colorAccent;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
|
||||
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = 4;
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = Math.min(14, h);
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const gap = 2;
|
||||
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
|
||||
const segH = h * 0.65;
|
||||
const y = (h - segH) / 2;
|
||||
const playedIdx = Math.floor(progress * SEG_COUNT);
|
||||
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const frac = i / SEG_COUNT;
|
||||
const x = i * (segW + gap);
|
||||
ctx.shadowBlur = 0;
|
||||
if (frac < progress) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
if (i === playedIdx - 1) {
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
}
|
||||
} else if (frac < buffered) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
} else {
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── new styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
// Ghost track — barely visible
|
||||
ctx.globalAlpha = 0.07;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
if (progress <= 0) return;
|
||||
|
||||
const px = progress * w;
|
||||
|
||||
// Wide outer glow
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillRect(0, cy - 5, px, 10);
|
||||
|
||||
// Mid glow
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillRect(0, cy - 2.5, px, 5);
|
||||
|
||||
// Inner glow
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - 1.5, px, 3);
|
||||
|
||||
// Bright white core
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 0.75, px, 1.5);
|
||||
|
||||
// End-cap flare
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawPulseWave(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
const t = animState.time;
|
||||
|
||||
// Base line
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played flat line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Animated pulse centered at playhead
|
||||
const pulseR = Math.min(38, w * 0.13);
|
||||
const amp = Math.min(h * 0.42, 5.5);
|
||||
const sigma = pulseR * 0.42;
|
||||
const startX = Math.max(0, px - pulseR);
|
||||
const endX = Math.min(w, px + pulseR);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, cy);
|
||||
for (let x = startX; x <= endX; x += 0.75) {
|
||||
const dx = x - px;
|
||||
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
|
||||
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
|
||||
ctx.lineTo(x, cy - wave);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawParticleTrail(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
|
||||
// Spawn particles at playhead based on movement
|
||||
const prevPx = animState.lastProgress * w;
|
||||
const moved = Math.abs(px - prevPx);
|
||||
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
|
||||
for (let i = 0; i < spawnN; i++) {
|
||||
animState.particles.push({
|
||||
x: px + (Math.random() - 0.5) * 3,
|
||||
y: cy + (Math.random() - 0.5) * (h * 0.55),
|
||||
vx: -(Math.random() * 1.0 + 0.3),
|
||||
vy: (Math.random() - 0.5) * 0.6,
|
||||
life: 1,
|
||||
maxLife: 25 + Math.random() * 35,
|
||||
size: Math.random() * 1.8 + 0.8,
|
||||
});
|
||||
}
|
||||
animState.lastProgress = progress;
|
||||
|
||||
// Update + cull
|
||||
for (const p of animState.particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy *= 0.97;
|
||||
p.life -= 1 / p.maxLife;
|
||||
}
|
||||
animState.particles = animState.particles.filter(p => p.life > 0);
|
||||
if (animState.particles.length > 180) {
|
||||
animState.particles = animState.particles.slice(-180);
|
||||
}
|
||||
|
||||
// Background line
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Particles
|
||||
ctx.shadowColor = played;
|
||||
for (const p of animState.particles) {
|
||||
ctx.globalAlpha = p.life * 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Playhead dot
|
||||
if (progress > 0) {
|
||||
const dx = Math.max(5, Math.min(w - 5, px));
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawLiquidFill(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const t = animState.time;
|
||||
|
||||
const tubeH = Math.min(13, Math.max(6, h * 0.62));
|
||||
const tubeR = tubeH / 2;
|
||||
const y0 = (h - tubeH) / 2;
|
||||
const y1 = y0 + tubeH;
|
||||
|
||||
// Glass tube background
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, y0, buffered * w, tubeH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
const px = progress * w;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
|
||||
// Liquid body with animated wave on top surface
|
||||
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
|
||||
const waveAmp = Math.min(2.0, tubeH * 0.14);
|
||||
const waveFreq = 0.09;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-1, y1 + 1);
|
||||
ctx.lineTo(-1, surfaceY);
|
||||
|
||||
for (let x = 0; x <= px + 1; x += 1) {
|
||||
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
|
||||
ctx.lineTo(x, surfaceY + wave);
|
||||
}
|
||||
ctx.lineTo(px + 1, y1 + 1);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 9;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Glass highlight on top
|
||||
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
|
||||
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
|
||||
hl.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillStyle = hl;
|
||||
ctx.fillRect(0, y0, px, tubeH * 0.45);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Tube outline (on top)
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawRetroTape(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
_buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
animState.angle += 0.042;
|
||||
|
||||
const maxR = Math.floor(h / 2) - 1;
|
||||
const minR = Math.max(2, maxR * 0.28);
|
||||
// supply reel (left): shrinks as progress increases
|
||||
const rL = minR + (maxR - minR) * (1 - progress);
|
||||
// takeup reel (right): grows as progress increases
|
||||
const rR = minR + (maxR - minR) * progress;
|
||||
const lx = maxR + 1;
|
||||
const rx = w - maxR - 1;
|
||||
|
||||
// Tape between reels
|
||||
const tapeLeft = lx + rL + 0.5;
|
||||
const tapeRight = rx - rR - 0.5;
|
||||
if (tapeRight > tapeLeft) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(tapeLeft, cy - 1.5, tapeRight - tapeLeft, 3);
|
||||
}
|
||||
|
||||
const drawReel = (
|
||||
cx: number,
|
||||
radius: number,
|
||||
angle: number,
|
||||
isRight: boolean,
|
||||
) => {
|
||||
const color = isRight ? played : unplayed;
|
||||
const alpha = isRight ? (progress > 0 ? 1 : 0.35) : (progress < 1 ? 0.55 : 0.35);
|
||||
const glowing = isRight && progress > 0;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
if (glowing) { ctx.shadowColor = played; ctx.shadowBlur = 6; }
|
||||
|
||||
// Outer ring
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, radius * 0.3);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spokes (only if reel is large enough)
|
||||
if (radius > hubR + 2.5) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = color;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(cx + Math.cos(a) * (radius - 0.5), cy + Math.sin(a) * (radius - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawReel(lx, rL, -animState.angle, false);
|
||||
drawReel(rx, rR, animState.angle, true);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── dispatcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function drawSeekbar(
|
||||
canvas: HTMLCanvasElement,
|
||||
style: SeekbarStyle,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState?: AnimState,
|
||||
) {
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
case 'segmented': drawSegmented(canvas, progress, buffered); break;
|
||||
case 'neon': drawNeon(canvas, progress, buffered); break;
|
||||
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
|
||||
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
|
||||
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
|
||||
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SeekbarPreview (animated, for Settings) ───────────────────────────────────
|
||||
|
||||
export function SeekbarPreview({
|
||||
style,
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
style: SeekbarStyle;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
const tick = () => {
|
||||
t += 0.016;
|
||||
animState.time = t;
|
||||
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
|
||||
const buffered = Math.min(1, progress + 0.18);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered, animState);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--ctp-base))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
alignItems: 'stretch',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: 24, display: 'block' }}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
fontWeight: selected ? 600 : 400,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
const animStateRef = useRef<AnimState>(makeAnimState());
|
||||
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
@@ -144,21 +804,47 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
// Static styles: redraw on progress / buffered / track changes
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
if (canvasRef.current) {
|
||||
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
|
||||
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId]);
|
||||
}, [progress, buffered, trackId, seekbarStyle]);
|
||||
|
||||
// Animated styles: rAF loop
|
||||
useEffect(() => {
|
||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
animStateRef.current = makeAnimState();
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(
|
||||
canvas,
|
||||
seekbarStyle,
|
||||
heightsRef.current,
|
||||
progressRef.current,
|
||||
bufferedRef.current,
|
||||
animStateRef.current,
|
||||
);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
|
||||
@@ -19,6 +19,7 @@ export const deTranslation = {
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
@@ -144,6 +145,13 @@ export const deTranslation = {
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbewertung',
|
||||
artistShort: 'Künstlerbewertung',
|
||||
albumAriaLabel: 'Albumbewertung',
|
||||
artistAriaLabel: 'Künstlerbewertung',
|
||||
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
albums: 'Alben',
|
||||
@@ -243,6 +251,17 @@ export const deTranslation = {
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
select: 'Mehrfachauswahl',
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
selectionCount: '{{count}} ausgewählt',
|
||||
downloadZips: 'ZIPs herunterladen',
|
||||
addOffline: 'Offline hinzufügen',
|
||||
downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
|
||||
downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
|
||||
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
|
||||
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
|
||||
},
|
||||
artists: {
|
||||
title: 'Künstler',
|
||||
@@ -436,6 +455,10 @@ 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.',
|
||||
useCustomTitlebar: 'Eigene Titelleiste',
|
||||
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
|
||||
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',
|
||||
@@ -496,6 +519,18 @@ export const deTranslation = {
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
ratingsSectionTitle: 'Bewertungen',
|
||||
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
||||
ratingsSkipStarDesc:
|
||||
'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.',
|
||||
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
|
||||
ratingsMixFilterTitle: 'Filter nach Bewertung',
|
||||
ratingsMixFilterDesc:
|
||||
'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.',
|
||||
ratingsMixMinSong: 'Titel',
|
||||
ratingsMixMinAlbum: 'Alben',
|
||||
ratingsMixMinArtist: 'Interpreten',
|
||||
ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
@@ -527,6 +562,18 @@ export const deTranslation = {
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
seekbarWaveform: 'Wellenform',
|
||||
seekbarLinedot: 'Linie & Punkt',
|
||||
seekbarBar: 'Balken',
|
||||
seekbarThick: 'Dicker Balken',
|
||||
seekbarSegmented: 'Segmentiert',
|
||||
seekbarNeon: 'Neonröhre',
|
||||
seekbarPulsewave: 'Pulswelle',
|
||||
seekbarParticletrail: 'Partikel-Spur',
|
||||
seekbarLiquidfill: 'Flüssigkeit',
|
||||
seekbarRetrotape: 'Retro-Band',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Was ist neu',
|
||||
@@ -682,6 +729,10 @@ export const deTranslation = {
|
||||
lfmMinutesAgo: 'vor {{n}} Min.',
|
||||
lfmHoursAgo: 'vor {{n}} Std.',
|
||||
lfmDaysAgo: 'vor {{n}} Tagen',
|
||||
topRatedSongs: 'Bestbewertete Songs',
|
||||
topRatedArtists: 'Bestbewertete Künstler',
|
||||
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
|
||||
noRatedArtists: 'Noch keine bewerteten Künstler.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
@@ -775,6 +826,20 @@ 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!',
|
||||
noArtists: 'Alle Künstler herausgefiltert.',
|
||||
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
|
||||
filterCompilationsShort: 'Sampler ausblenden',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const enTranslation = {
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
@@ -145,6 +146,13 @@ export const enTranslation = {
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Album rating',
|
||||
artistShort: 'Artist rating',
|
||||
albumAriaLabel: 'Album rating',
|
||||
artistAriaLabel: 'Artist rating',
|
||||
saveFailed: 'Could not save rating.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
albums: 'Albums',
|
||||
@@ -244,6 +252,17 @@ export const enTranslation = {
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artists',
|
||||
@@ -437,6 +456,10 @@ 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.',
|
||||
useCustomTitlebar: 'Custom title bar',
|
||||
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
|
||||
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',
|
||||
@@ -481,6 +504,18 @@ export const enTranslation = {
|
||||
tabServer: 'Server',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
ratingsSectionTitle: 'Ratings',
|
||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||
ratingsSkipStarDesc:
|
||||
'After N skips in a row, set the track to 1★. Only for tracks not rated before.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips before 1★',
|
||||
ratingsMixFilterTitle: 'Filter by rating',
|
||||
ratingsMixFilterDesc:
|
||||
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
|
||||
ratingsMixMinSong: 'Songs',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artists',
|
||||
ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
@@ -528,6 +563,18 @@ export const enTranslation = {
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
seekbarStyle: 'Seekbar Style',
|
||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||
seekbarWaveform: 'Waveform',
|
||||
seekbarLinedot: 'Line & Dot',
|
||||
seekbarBar: 'Bar',
|
||||
seekbarThick: 'Thick Bar',
|
||||
seekbarSegmented: 'Segmented',
|
||||
seekbarNeon: 'Neon Glow',
|
||||
seekbarPulsewave: 'Pulse Wave',
|
||||
seekbarParticletrail: 'Particle Trail',
|
||||
seekbarLiquidfill: 'Liquid Fill',
|
||||
seekbarRetrotape: 'Retro Tape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "What's New",
|
||||
@@ -683,6 +730,10 @@ export const enTranslation = {
|
||||
lfmMinutesAgo: '{{n}}m ago',
|
||||
lfmHoursAgo: '{{n}}h ago',
|
||||
lfmDaysAgo: '{{n}}d ago',
|
||||
topRatedSongs: 'Top Rated Songs',
|
||||
topRatedArtists: 'Top Rated Artists',
|
||||
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
|
||||
noRatedArtists: 'No rated artists yet.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
@@ -776,6 +827,20 @@ 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!',
|
||||
noArtists: 'All artists filtered out.',
|
||||
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Hide compilations',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -144,6 +145,13 @@ export const frTranslation = {
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Note de l’album',
|
||||
artistShort: 'Note de l’artiste',
|
||||
albumAriaLabel: 'Note de l’album',
|
||||
artistAriaLabel: 'Note de l’artiste',
|
||||
saveFailed: 'Impossible d’enregistrer la note.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
albums: 'Albums',
|
||||
@@ -243,6 +251,17 @@ export const frTranslation = {
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistes',
|
||||
@@ -436,6 +455,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',
|
||||
@@ -496,6 +517,18 @@ export const frTranslation = {
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
ratingsSectionTitle: 'Notes',
|
||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||
ratingsSkipStarDesc:
|
||||
'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.',
|
||||
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
|
||||
ratingsMixFilterTitle: 'Filtrage par note',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.',
|
||||
ratingsMixMinSong: 'Morceaux',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artistes',
|
||||
ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
@@ -527,6 +560,18 @@ export const frTranslation = {
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
seekbarWaveform: 'Forme d\'onde',
|
||||
seekbarLinedot: 'Ligne & point',
|
||||
seekbarBar: 'Barre',
|
||||
seekbarThick: 'Barre épaisse',
|
||||
seekbarSegmented: 'Segmentée',
|
||||
seekbarNeon: 'Néon',
|
||||
seekbarPulsewave: 'Onde pulsée',
|
||||
seekbarParticletrail: 'Traînée de particules',
|
||||
seekbarLiquidfill: 'Tube liquide',
|
||||
seekbarRetrotape: 'Bande rétro',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Quoi de neuf',
|
||||
@@ -682,6 +727,10 @@ export const frTranslation = {
|
||||
lfmMinutesAgo: 'il y a {{n}} min',
|
||||
lfmHoursAgo: 'il y a {{n}}h',
|
||||
lfmDaysAgo: 'il y a {{n}}j',
|
||||
topRatedSongs: 'Morceaux les mieux notés',
|
||||
topRatedArtists: 'Artistes les mieux notés',
|
||||
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
||||
noRatedArtists: 'Aucun artiste noté.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Lecteur de musique',
|
||||
@@ -775,6 +824,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',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const nbTranslation = {
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
@@ -144,6 +145,13 @@ export const nbTranslation = {
|
||||
ratingLabel: 'Vurdering',
|
||||
enlargeCover: 'Forstørr',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumvurdering',
|
||||
artistShort: 'Artistvurdering',
|
||||
albumAriaLabel: 'Albumvurdering',
|
||||
artistAriaLabel: 'Artistvurdering',
|
||||
saveFailed: 'Kunne ikke lagre vurderingen.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Tilbake',
|
||||
albums: 'Album',
|
||||
@@ -242,7 +250,18 @@ export const nbTranslation = {
|
||||
yearFrom: 'Fra',
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
yearFilterLabel: 'År',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artister',
|
||||
@@ -435,6 +454,8 @@ export const nbTranslation = {
|
||||
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',
|
||||
@@ -479,6 +500,18 @@ export const nbTranslation = {
|
||||
tabServer: 'Tjener',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Generelt',
|
||||
ratingsSectionTitle: 'Vurderinger',
|
||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||
ratingsSkipStarDesc:
|
||||
'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
|
||||
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
|
||||
ratingsMixMinSong: 'Spor',
|
||||
ratingsMixMinAlbum: 'Album',
|
||||
ratingsMixMinArtist: 'Artister',
|
||||
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
|
||||
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
||||
backupExport: 'Eksporter innstillinger',
|
||||
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
||||
@@ -526,6 +559,18 @@ export const nbTranslation = {
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
seekbarWaveform: 'Bølgeform',
|
||||
seekbarLinedot: 'Linje & punkt',
|
||||
seekbarBar: 'Linje',
|
||||
seekbarThick: 'Tykk linje',
|
||||
seekbarSegmented: 'Segmentert',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsbølge',
|
||||
seekbarParticletrail: 'Partikkelspor',
|
||||
seekbarLiquidfill: 'Væskerør',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
@@ -681,6 +726,10 @@ export const nbTranslation = {
|
||||
lfmMinutesAgo: 'For {{n}}m siden',
|
||||
lfmHoursAgo: 'For {{n}}t siden',
|
||||
lfmDaysAgo: 'For {{n}}d siden',
|
||||
topRatedSongs: 'Høyest vurderte spor',
|
||||
topRatedArtists: 'Høyest vurderte artister',
|
||||
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
||||
noRatedArtists: 'Ingen vurderte artister ennå.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikkspiller',
|
||||
@@ -774,6 +823,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',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const nlTranslation = {
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
@@ -144,6 +145,13 @@ export const nlTranslation = {
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbeoordeling',
|
||||
artistShort: 'Artiestbeoordeling',
|
||||
albumAriaLabel: 'Albumbeoordeling',
|
||||
artistAriaLabel: 'Artiestbeoordeling',
|
||||
saveFailed: 'Beoordeling opslaan mislukt.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
albums: 'Albums',
|
||||
@@ -243,6 +251,17 @@ export const nlTranslation = {
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artiesten',
|
||||
@@ -436,6 +455,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',
|
||||
@@ -496,6 +517,18 @@ export const nlTranslation = {
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
ratingsSectionTitle: 'Beoordelingen',
|
||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||
ratingsSkipStarDesc:
|
||||
'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
|
||||
ratingsMixFilterTitle: 'Filter op beoordeling',
|
||||
ratingsMixFilterDesc:
|
||||
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
|
||||
ratingsMixMinSong: 'Nummers',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artiesten',
|
||||
ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
@@ -527,6 +560,18 @@ export const nlTranslation = {
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
seekbarWaveform: 'Golfvorm',
|
||||
seekbarLinedot: 'Lijn & punt',
|
||||
seekbarBar: 'Balk',
|
||||
seekbarThick: 'Dikke balk',
|
||||
seekbarSegmented: 'Gesegmenteerd',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsgolf',
|
||||
seekbarParticletrail: 'Deeltjesspoor',
|
||||
seekbarLiquidfill: 'Vloeistofbuis',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Wat is nieuw',
|
||||
@@ -682,6 +727,10 @@ export const nlTranslation = {
|
||||
lfmMinutesAgo: '{{n}} min geleden',
|
||||
lfmHoursAgo: '{{n}} uur geleden',
|
||||
lfmDaysAgo: '{{n}} dagen geleden',
|
||||
topRatedSongs: 'Best beoordeelde nummers',
|
||||
topRatedArtists: 'Best beoordeelde artiesten',
|
||||
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
||||
noRatedArtists: 'Nog geen beoordeelde artiesten.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Muziekspeler',
|
||||
@@ -775,6 +824,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',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ruTranslation = {
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
@@ -146,6 +147,13 @@ export const ruTranslation = {
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Оценка альбома',
|
||||
artistShort: 'Оценка исполнителя',
|
||||
albumAriaLabel: 'Оценка альбома',
|
||||
artistAriaLabel: 'Оценка исполнителя',
|
||||
saveFailed: 'Не удалось сохранить оценку.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Назад',
|
||||
albums: 'Альбомы',
|
||||
@@ -252,6 +260,17 @@ export const ruTranslation = {
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Исполнители',
|
||||
@@ -452,9 +471,14 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
@@ -501,6 +525,18 @@ export const ruTranslation = {
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
tabGeneral: 'Общие',
|
||||
ratingsSectionTitle: 'Рейтинги',
|
||||
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||
ratingsSkipStarDesc:
|
||||
'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
|
||||
ratingsSkipStarThresholdLabel: 'Скипов',
|
||||
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
||||
ratingsMixFilterDesc:
|
||||
'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
|
||||
ratingsMixMinSong: 'Песни',
|
||||
ratingsMixMinAlbum: 'Альбомы',
|
||||
ratingsMixMinArtist: 'Исполнители',
|
||||
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
|
||||
backupTitle: 'Резервная копия',
|
||||
backupExport: 'Экспорт настроек',
|
||||
backupExportDesc:
|
||||
@@ -550,6 +586,18 @@ export const ruTranslation = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
seekbarLinedot: 'Линия и точка',
|
||||
seekbarBar: 'Полоса',
|
||||
seekbarThick: 'Толстая полоса',
|
||||
seekbarSegmented: 'Сегменты',
|
||||
seekbarNeon: 'Неон',
|
||||
seekbarPulsewave: 'Пульс-волна',
|
||||
seekbarParticletrail: 'Частицы',
|
||||
seekbarLiquidfill: 'Жидкость',
|
||||
seekbarRetrotape: 'Ретро-лента',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
@@ -739,6 +787,10 @@ export const ruTranslation = {
|
||||
lfmMinutesAgo: '{{n}} мин назад',
|
||||
lfmHoursAgo: '{{n}} ч назад',
|
||||
lfmDaysAgo: '{{n}} дн. назад',
|
||||
topRatedSongs: 'Лучшие по рейтингу треки',
|
||||
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
||||
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
||||
noRatedArtists: 'Нет оценённых исполнителей.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Плеер',
|
||||
@@ -829,6 +881,17 @@ export const ruTranslation = {
|
||||
removeCover: 'Убрать фото',
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист сохранён',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const zhTranslation = {
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
@@ -144,6 +145,13 @@ export const zhTranslation = {
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: '专辑评分',
|
||||
artistShort: '艺人评分',
|
||||
albumAriaLabel: '专辑评分',
|
||||
artistAriaLabel: '艺人评分',
|
||||
saveFailed: '无法保存评分。',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
albums: '专辑',
|
||||
@@ -243,6 +251,17 @@ export const zhTranslation = {
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
@@ -432,6 +451,8 @@ export const zhTranslation = {
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
|
||||
discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
@@ -476,6 +497,18 @@ export const zhTranslation = {
|
||||
tabServer: '服务器',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
ratingsSectionTitle: '评分',
|
||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||
ratingsSkipStarDesc:
|
||||
'连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
||||
ratingsMixFilterTitle: '按评分筛选',
|
||||
ratingsMixFilterDesc:
|
||||
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
|
||||
ratingsMixMinSong: '歌曲',
|
||||
ratingsMixMinAlbum: '专辑',
|
||||
ratingsMixMinArtist: '艺人',
|
||||
ratingsMixMinThresholdAria: '最低星数:{{label}}',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
@@ -523,6 +556,18 @@ export const zhTranslation = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
seekbarLinedot: '线条与点',
|
||||
seekbarBar: '条形',
|
||||
seekbarThick: '粗条形',
|
||||
seekbarSegmented: '分段式',
|
||||
seekbarNeon: '霓虹',
|
||||
seekbarPulsewave: '脉冲波',
|
||||
seekbarParticletrail: '粒子轨迹',
|
||||
seekbarLiquidfill: '液体填充',
|
||||
seekbarRetrotape: '复古磁带',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: '新功能',
|
||||
@@ -678,6 +723,10 @@ export const zhTranslation = {
|
||||
lfmMinutesAgo: '{{n}} 分钟前',
|
||||
lfmHoursAgo: '{{n}} 小时前',
|
||||
lfmDaysAgo: '{{n}} 天前',
|
||||
topRatedSongs: '最高评分歌曲',
|
||||
topRatedArtists: '最高评分艺人',
|
||||
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
||||
noRatedArtists: '暂无已评分艺人。',
|
||||
},
|
||||
player: {
|
||||
regionLabel: '音乐播放器',
|
||||
@@ -771,6 +820,17 @@ export const zhTranslation = {
|
||||
removeCover: '删除照片',
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
topArtists: '热门艺术家',
|
||||
topAlbums: '热门专辑',
|
||||
plays: '播放 {{n}} 次',
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
|
||||
@@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -33,6 +34,7 @@ export default function AlbumDetail() {
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -52,6 +54,11 @@ export default function AlbumDetail() {
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineJobs = useOfflineStore(s => s.jobs);
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||
|
||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||
|
||||
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
|
||||
if (!album) return 'none';
|
||||
@@ -90,6 +97,11 @@ export default function AlbumDetail() {
|
||||
}).catch(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||||
}, [id, album?.album.id, album?.album.userRating]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
@@ -126,9 +138,37 @@ const handleEnqueueAll = () => {
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
await setRating(songId, rating);
|
||||
};
|
||||
|
||||
const handleAlbumEntityRating = async (rating: number) => {
|
||||
if (!album || album.album.id !== id) return;
|
||||
const albumId = album.album.id;
|
||||
const ratingAtStart = album.album.userRating ?? 0;
|
||||
|
||||
setAlbumEntityRating(rating);
|
||||
|
||||
if (albumEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(albumId, rating);
|
||||
setAlbum(cur =>
|
||||
cur && cur.album.id === albumId
|
||||
? { ...cur, album: { ...cur.album, userRating: rating } }
|
||||
: cur,
|
||||
);
|
||||
} catch (err) {
|
||||
setAlbumEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(serverId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBio = async () => {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
@@ -270,6 +310,9 @@ const handleEnqueueAll = () => {
|
||||
offlineProgress={offlineProgress}
|
||||
onCacheOffline={handleCacheOffline}
|
||||
onRemoveOffline={handleRemoveOffline}
|
||||
entityRatingValue={albumEntityRating}
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
/>
|
||||
{offlineStorageFull && (
|
||||
<div className="offline-storage-full-banner" role="alert">
|
||||
@@ -290,6 +333,7 @@ const handleEnqueueAll = () => {
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
ratings={ratings}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
starredSongs={new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { X } from 'lucide-react';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
@@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const { downloadAlbum } = useOfflineStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [sort, setSort] = useState<SortType>('alphabeticalByName');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,6 +44,73 @@ export default function Albums() {
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
let done = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
|
||||
try {
|
||||
const url = buildDownloadUrl(album.id);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
done++;
|
||||
} catch (e) {
|
||||
console.error('ZIP download failed for', album.name, e);
|
||||
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
||||
}
|
||||
}
|
||||
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleAddOffline = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
const fromNum = parseInt(yearFrom, 10);
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
@@ -108,58 +189,87 @@ export default function Albums() {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('albums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Year range filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
style={{ padding: '4px 6px' }}
|
||||
>
|
||||
<X size={13} />
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
style={{ padding: '4px 6px' }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +280,15 @@ export default function Albums() {
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -179,6 +297,7 @@ export default function Albums() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
@@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -71,6 +72,11 @@ export default function ArtistDetail() {
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -94,6 +100,34 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
}, [id, artist?.id, artist?.userRating]);
|
||||
|
||||
const handleArtistEntityRating = async (rating: number) => {
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
@@ -351,6 +385,16 @@ export default function ArtistDetail() {
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
|
||||
@@ -4,11 +4,12 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import {
|
||||
getStarred, getInternetRadioStations,
|
||||
getStarred, getInternetRadioStations, setRating,
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,7 +21,8 @@ const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
@@ -39,14 +41,23 @@ export default function Favorites() {
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
@@ -179,11 +190,20 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
const isCentered = key === 'duration';
|
||||
const isCentered = key === 'duration' || key === 'rating';
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
@@ -255,6 +275,13 @@ export default function Favorites() {
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
case 'duration': return (
|
||||
<div key="duration" className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
|
||||
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
const HOME_HERO_COUNT = 8;
|
||||
const HOME_DISCOVER_SLICE = 20;
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -23,30 +32,50 @@ export default function Home() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||
const [s, n, rRaw, f, rp, artists] = await Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', randomSize).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion, homeSections]);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
musicLibraryFilterVersion,
|
||||
homeSections,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
@@ -55,7 +84,10 @@ export default function Home() {
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const batch =
|
||||
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
||||
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
@@ -134,6 +166,7 @@ export default function Home() {
|
||||
{isVisible('mostPlayed') && (
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
titleLink="/most-played"
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
|
||||
@@ -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' : '',
|
||||
|
||||