Compare commits

...

356 Commits

Author SHA1 Message Date
Psychotoxical e1b807d0ef chore(aur): bump pkgver to 1.34.12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:33:17 +02:00
Psychotoxical d18646de23 chore(release): bump version to 1.34.12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:20:40 +02:00
Psychotoxical ccdb899833 fix(theme): improve Latte and GTA theme readability
Latte: fix server dropdown dark background (--bg-card instead of hardcoded #1e1e2e), add back-button white text over album cover overlay, fix NowPlaying white-on-white text (artist/album, badges, bio, tracklist, star/heart icons use semantic vars).

GTA: bump --text-muted from #484848 to #707070 and --text-primary from #e8e8e8 to #d8d8d8 — sidebar section labels, Settings descriptions and album detail info were nearly invisible on the near-black background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:44:18 +02:00
Psychotoxical b197694804 chore(about): add PRs #196, #198, #199, #200, #201 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:00:02 +02:00
Frank Stellmacher b45bb95522 Merge pull request #201 from cucadmuh/feat/playback-source-indicator
feat(queue): playback source badge + preload-aware source tracking
2026-04-17 15:56:01 +02:00
Psychotoxical cd1c785e43 fix(queue): use data-tooltip instead of title for source badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:55:48 +02:00
Maxim Isaev 1e54946939 fix(audio): label streaming decoder logs by source type
Differentiate streaming decoder init/decode messages between queue track streaming and radio so errors are not misattributed to radio playback.
2026-04-17 16:43:43 +03:00
Psychotoxical 34e374cf03 fix(macos): remove NSMicrophoneUsageDescription to suppress spurious mic prompt
cpal/CoreAudio triggers macOS TCC mic check during output device enumeration.
Without NSMicrophoneUsageDescription, macOS silently denies input access instead
of showing a dialog — output playback is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:38:40 +02:00
Frank Stellmacher 9387ceca83 Merge pull request #198 from kveld9/feat/DRP-enhancement
feat: discord rich presence enhancement
2026-04-17 15:34:18 +02:00
Maxim Isaev c165669db5 feat(queue): show playback source in tech strip with preload tracking
Add source badges for offline, cache, and stream playback in the queue tech line, including localized tooltips across shipped locales.

Track Rust preload-ready events by stream track id and latch the selected source per track, with dev logs to debug preload/source mismatches during next-track handoff.
2026-04-17 16:34:11 +03:00
Psychotoxical c50addaacf fix(discord): remove dead fields, timeChanged invoke loop, and unsupported {paused} placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:33:58 +02:00
Frank Stellmacher 7e64ef8f2a Merge pull request #199 from kveld9/fix/csv-import-clean
fix: csv import improvements
2026-04-17 15:28:19 +02:00
Frank Stellmacher 8c87ba95ff Merge pull request #196 from cucadmuh/feat/issue-195-queue-replaygain
feat(queue): ReplayGain in current-track tech strip
2026-04-17 15:28:16 +02:00
Frank Stellmacher 54962a15c1 Merge pull request #200 from cucadmuh/feat/streaming-playback-stability
fix(audio): streaming start stability, seek recovery, and transition safeguards
2026-04-17 15:24:09 +02:00
Maxim Isaev 689d2dc019 fix(audio): harden stream playback transitions and cache promotion
Add resilient stream-first track playback with reconnect/range recovery, seek fallback handling for non-seekable starts, and promotion of completed streamed bytes into hot cache.

Also add crossfade/gapless backup preload windows to keep automatic transitions smooth on imperfect networks.
2026-04-17 13:49:31 +03:00
kveld9 f3fb730fbc csv import improvements 2026-04-17 02:55:31 -03:00
kveld9 5576fb0d13 fix DRP timer 2026-04-16 21:41:22 -03:00
kveld9 99c6720b86 fix Settings.tsx and authStore.ts 2026-04-16 21:05:56 -03:00
kveld9 7b20965c1b fix locales 2026-04-16 20:56:20 -03:00
kveld9 28844e8456 release 2026-04-16 20:34:55 -03:00
Maxim Isaev 572eb81927 feat(queue): show ReplayGain metadata in current-track tech strip
Display track gain, album gain, and peak from file tags next to format,
bitrate, and sample rate so missing ReplayGain tags are obvious.

Refs: https://github.com/Psychotoxical/psysonic/issues/195
2026-04-16 23:15:13 +03:00
Psychotoxical b6812de26b remove(seekbar): drop realtime_waveform style
Downloaded and fully decoded the entire audio file on every track
change — too CPU/bandwidth intensive to keep. Removes SeekbarStyle
variant, Rust command, waveform_decode function, all frontend canvas
code, seekbarRealtime_waveform i18n keys across all 8 locales, and
unused urlencoding dependency from Cargo.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:15:48 +02:00
Psychotoxical 2f1d52e100 docs(readme): shorten features list and remove completed roadmap
Condensed 27 feature bullets to 14 key highlights, replaced exact
theme count with "wide selection", added CLI Control entry, and
removed the completed roadmap section (redundant with features list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:38:12 +02:00
Psychotoxical 06902acd4b feat(help): add missing sections and questions (Device Sync, Internet Radio, CLI, Playlists, Infinite Queue, Lyrics sources, Audio device, Backup & Restore, Now Playing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:10:40 +02:00
Psychotoxical 2deaed50b8 chore(about): add missing features from cucadmuh PR #187 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:58:05 +02:00
Psychotoxical 3ac82595f9 docs(privacy): add NetEase Cloud Music lyrics source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:50:28 +02:00
Psychotoxical 7db42d74ef feat: tracklist column reset, device sync cross-platform template fix, filename template builder, privacy policy
- Add "Reset to defaults" button to column picker in AlbumTrackList, PlaylistDetail, Favorites (all 8 locales)
- Fix device sync cross-OS status detection: store filenameTemplate in manifest, use it for path computation on import
- Redesign filename template UI: preset buttons (Standard/Multi-Disc/Alt. Folder), clickable token chips ({artist}, {album}, etc.), clear button
- Fix suggestions section in PlaylistDetail missing album column rendering
- Add PRIVACY.md documenting all third-party integrations (Last.fm, LRCLIB, Apple Music, Discord); link from README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:47:41 +02:00
Psychotoxical 58851da817 Update README.md 2026-04-16 08:45:51 +02:00
Psychotoxical 3262458cd3 Update README.md 2026-04-16 08:45:18 +02:00
Psychotoxical bf3d896016 fix(device-sync): auto-import manifest on mount, clear view on disconnect
- Read psysonic-sync.json automatically when DeviceSync page opens and
  drive is already connected (no manual folder re-select needed)
- Always replace sources from manifest when choosing a folder, so
  switching between sticks loads the correct album list
- Hide source list and status badges when drive is disconnected
- Reset import flag on disconnect so re-plugging triggers a fresh import
- Fix unused `mut` warning in cancel_device_sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 23:16:06 +02:00
Psychotoxical 442ed9d191 feat(device-sync): manifest, cancel, font picker + sync status fix
- Write psysonic-sync.json to device after sync/deletions; auto-import
  on folder select when localStorage is empty (cross-platform handoff)
- Add cancel button during active sync: AtomicBool flag per job,
  tasks bail after acquiring semaphore, cancelled state in UI
- Fix sync status staying "pending": normalize template path separators
  to OS separator (Windows: '/' → '\') so compute_sync_paths and
  list_device_dir_files produce matching strings for Set comparison
- Add Geist and JetBrains Mono as variable fonts; font picker collapsible
- Fix device mount detection on Windows: removable drive letters (E:\)
  were incorrectly skipped alongside system roots; strip \?\ prefix
  from canonicalized paths before mount-point comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:59:27 +02:00
Psychotoxical e23280c013 feat(sidebar): add split Mix navigation mode with separate sidebar entries
Extract ALL_NAV_ITEMS to src/config/navItems.ts and add randomMix/randomAlbums
nav items. A new toggle in Settings switches between a single "Build a Mix" hub
and separate "Random Mix" / "Random Albums" sidebar entries (randomNavMode in
authStore). Fixes reorder crash caused by hidden items being overwritten with
undefined during the merge step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:27:53 +02:00
Psychotoxical 189bb519eb chore(about): add PR #184–#192 to contributors list
cucadmuh: CLI player controls + D-Bus forwarding + shell completions (PR #187)
kveld9: Favorites redesign, albums/playlists headers, column picker fixes,
        CSV import, search context menu (PR #184, #186, #188, #190, #191, #192)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:04:14 +02:00
Psychotoxical c573784fbb Merge pull request #192: fix tracklist column picker alignment and toggle functionality 2026-04-15 20:59:49 +02:00
kveld9 03b56afc92 bug fixes 2026-04-15 15:26:56 -03:00
Psychotoxical f437017359 fix(cli): gate OnceLock import behind linux cfg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:03:56 +02:00
Psychotoxical 34acc46c12 fix(cli): suppress dead-code warnings on Windows for Linux-only helpers
Wrap tauri_identifier, single_instance_bus_name and single_instance_object_path
with #[cfg(target_os = "linux")] since they are only reachable via D-Bus paths.
Move the argv collection into the linux-only block in lib.rs for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:01:17 +02:00
Psychotoxical d248a78acc fix(tracklist): revert overflow-x:visible regression from PR #188
.content-body has overflow-x:hidden — setting overflow-x:visible on
.tracklist would clip wide tables instead of scrolling them. Keep auto.
Also remove Spanish code comments (contributors are international).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:35:28 +02:00
Psychotoxical a4d8dd41fd Merge pull request #188 from kveld9/fix/tracklist-picker-overflow 2026-04-15 18:34:36 +02:00
Psychotoxical b7a2e91a62 fix(search): remove duplicate const total in AdvancedSearch
PR #191 moved the declaration to the top of the component but left
the original at line 139, causing a 'has already been declared' error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:32:47 +02:00
Psychotoxical d519042917 Merge pull request #191 from kveld9/feat/search-context-menu
added context menu to search panel songs
2026-04-15 18:27:06 +02:00
Psychotoxical ccbf3605a0 chore: update package-lock after moving @types/papaparse to devDeps 2026-04-15 18:22:30 +02:00
Psychotoxical fce8dc3208 fix(csv-import): guard s.isrc type before toUpperCase, restore public toggle, move @types to devDeps
- s.isrc from Subsonic API may be non-string at runtime; add typeof guard
  to prevent TypeError crashing all 80 search calls into "network errors"
- Restore isPublic toggle in PlaylistEditModal footer (accidentally removed
  in PR #190); keep new Cancel button alongside it
- Move @types/papaparse from dependencies to devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:21:23 +02:00
Psychotoxical 2384b73908 fix(post-merge #190): restore public-toggle in PlaylistEditModal, move @types/papaparse to devDeps
- PlaylistEditModal footer lost the isPublic toggle in the CSV import PR;
  restore it alongside the new Cancel button
- @types/papaparse is a type-only package — belongs in devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:10:19 +02:00
Psychotoxical 7cd0eaf9e1 Merge pull request #190 from kveld9/feat/playlist-csv-import
feature: spotify CSV playlist import
2026-04-15 18:09:27 +02:00
Psychotoxical 9383a6f6ed feat(radio): forward ICY StreamTitle to MPRIS metadata
When a radio stream is active, push the station name to MPRIS on start
and forward ICY StreamTitle updates (radio:metadata events) so the OS
now-playing overlay stays in sync. Skips ad metadata (is_ad flag) and
suppresses position ticks for radio streams (no meaningful duration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 624bd6bff7 fix(i18n): add common.play key to all 8 locales (fix for PR #186)
The AlbumHeader and PlaylistDetail play buttons use t('common.play') which
was missing — 'Reproducir' would have appeared as fallback in all languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 67f02def43 Merge pull request #186 from kveld9/redesign/albums-playlists-headers
redesign/albums playlists headers
2026-04-15 18:02:16 +02:00
Psychotoxical 1a2bc3f65b Merge pull request #187 from cucadmuh/feat/cli-completions-player-controls
Reviewed and merged. Clean architecture, correct D-Bus forwarding design, solid completions scripts. Minor nits noted in review (parse_cli_command called 4x, search fetches all types, 500ms sleep comment) — none blocking.
2026-04-15 17:56:45 +02:00
kveld9 56d025f150 added context menu to search panel songs 2026-04-15 12:36:32 -03:00
kveld9 602811908b translations 2026-04-15 12:21:13 -03:00
kveld9 fc90617e9c some fixes 2026-04-15 11:50:44 -03:00
kveld9 a44581e5d6 new feature: import spotify playlist with .csv 2026-04-15 11:19:31 -03:00
kveld9 eb061bd9de fix album tracklist column picker cutoff and scroll containment issues 2026-04-14 23:27:57 -03:00
Maxim Isaev 6533991e7d feat(cli): expand remote player, opaque play id, tray without libindicator
Add CLI commands for stop, shuffle, repeat, mute, star, rating, reload,
server list/switch, and Subsonic search; publish extra snapshot fields and
server/search JSON for tooling. Single `play <id>` resolves track, album,
or artist; artists play a shuffled full discography.

Guard Linux tray creation with catch_unwind when Ayatana/AppIndicator .so
is missing. Harden bash completion against zsh sourcing (compopt). Narrow
AudioEngine field visibility to silence private_interfaces warnings.
2026-04-15 01:47:46 +03:00
Maxim Isaev c75297fcf6 feat(cli): completions, library/audio commands, server switcher
Embed bash and zsh completion scripts and add a `completions` subcommand.
Extend the player CLI (library, audio device, instant mix) and surface
`music_library` in snapshots. Add a shared active-server switcher in the
header and locales; instant mix can reseed the queue from CLI and UI.
2026-04-15 01:47:46 +03:00
Psychotoxical 19b7c8ec06 fix(favorites): correct post-merge issues from PR #184
- btn-sm: add global .btn-sm modifier to theme.css (was only scoped inside device-sync)
- year filter: exclude songs without year metadata when filter is active
- showing count: only render "X of Y" label when a filter is actually active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:38:41 +02:00
kveld9 c3ebc9b951 some fixes 2026-04-14 17:38:08 -03:00
Psychotoxical 117be0d85e Merge pull request #184 from kveld9/feat/ux-redesign-favorites
feat: ux redesign favorites -- sortable columns, gender filter, age range filter, new columns.
2026-04-14 22:34:18 +02:00
Psychotoxical 12818e02f8 chore(aur): bump pkgver to 1.34.11, add cmake makedepend
cmake is required by symphonia-adapter-libopus (bundles libopus via CMake).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:30:15 +02:00
kveld9 7ceaf4d5f2 merge branch 'main' into redesign 2026-04-14 17:29:57 -03:00
Psychotoxical 76da684c8d ci: add cmake to Linux build dependencies
Required by symphonia-adapter-libopus which bundles and compiles
libopus from source via CMake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:59 +02:00
Psychotoxical 795701a447 chore(release): bump version to 1.34.11
- Changelog for v1.34.11 (Opus, Device Sync, community themes, SSL fix)
- Contributors updated in Settings About (PR #181, #182, #183)
- Version bumped to 1.34.11 in package.json, tauri.conf.json, Cargo.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:15 +02:00
kveld9 5340d696cf new design 2026-04-14 17:26:02 -03:00
Psychotoxical 0a14123a0b docs(readme): add cmake as required build prerequisite
symphonia-adapter-libopus bundles libopus and compiles it via cmake.
Without cmake installed, cargo build fails with a C compiler error.
Added install instructions for Linux, macOS, and Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:04:25 +02:00
Psychotoxical 7cd03248c8 Merge pull request #183 from cucadmuh/feat/opus-support
feat(audio): Opus playback via symphonia-adapter-libopus
2026-04-14 21:58:22 +02:00
Maxim Isaev 4c6fc4d6d5 merge: integrate origin/main into feat/opus-support 2026-04-14 22:52:35 +03:00
kveld9 6996dacdf7 sortable columns, gender filter, age range filter, new columns. 2026-04-14 16:49:06 -03:00
Psychotoxical 798812d5ef customization: 8 new themes have been implemented
customization: 8 new themes have been implemented
2026-04-14 21:47:51 +02:00
Psychotoxical 9f4c7382aa feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
2026-04-14 21:47:48 +02:00
Psychotoxical d34de09673 feat(device-sync): overhaul Device Sync page
- New two-panel layout with live album search (search3, 300ms debounce)
  and 10 random albums when search is empty — replaces full paginated load
- Pre-sync summary modal: shows files to add/delete, net change, available
  space; Proceed button disabled when space is insufficient
- calculate_sync_payload now filters already-synced tracks (path exists on
  device) so add_bytes/add_count reflect the true delta
- Space check accounts for pending deletions: addBytes > availableBytes + delBytes
- Separate deviceSyncJobStore for ephemeral job progress state
- Removable drive detection + auto-poll every 5 s via sysinfo
- Desired-state diff: de-selecting a synced source stages it for deletion
  instead of silently removing it
- Status badges (Synced / Pending / Deletion) with matching row highlights
- Live-Search badge () and Zufallsalben section label (⇌) in album browser
- Status summary row height aligned with search field (52 px)
- i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:37:35 +02:00
Maxim Isaev 1ff4f1ef0f feat(audio): add Opus playback via symphonia-adapter-libopus
Wire a lazy CodecRegistry built with symphonia::default::register_enabled_codecs
plus symphonia_adapter_libopus::OpusDecoder for SizedDecoder and radio decoders.

Raise package rust-version to 1.89 to match the MSRV required by
symphonia-adapter-libopus.
2026-04-14 22:17:29 +03:00
kveld9 9a9be253e3 8 new themes have been implemented 2026-04-14 14:40:23 -03:00
kveld9 38e59d7a5e feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate) 2026-04-14 14:21:48 -03:00
Psychotoxical 915f0143f7 fix(ssl): strip trailing slash in getBaseUrl + trust OS certificate store for HTTPS streaming
Fixes two bugs reported in #178:

- getBaseUrl() now strips trailing slashes from server URLs, consistent
  with restBaseFromUrl(). A trailing slash caused double-slash stream URLs
  (//rest/stream.view) which Caddy rejects. Trailing slash also caused
  browsing to return 0 results. Fix manually ported from PR #179 by kveld9.

- reqwest now loads the OS native certificate store in addition to
  Mozilla's webpki roots (rustls-tls-native-roots feature). Fixes HTTPS
  streaming failures where the server certificate is signed by a local CA
  (e.g. Caddy internal CA) that is trusted in the system keychain but not
  in Mozilla's root store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:16:08 +02:00
Psychotoxical e0bb02b65e Update CHANGELOG.md 2026-04-14 11:48:51 +02:00
Psychotoxical 0d5011c5be Update CHANGELOG.md 2026-04-14 11:48:01 +02:00
Psychotoxical 34d899806a Update CHANGELOG.md 2026-04-14 11:47:01 +02:00
Psychotoxical 928cdba17a Update CHANGELOG.md 2026-04-14 11:42:55 +02:00
Psychotoxical 17a5c92174 feat(device-sync): USB/SD card sync page (WIP)
Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:02:13 +02:00
Psychotoxical 58f99ae846 chore: bump version to 1.34.11-dev 2026-04-13 22:25:49 +02:00
Psychotoxical ef9a620ac6 chore(aur): bump pkgver to 1.34.10 2026-04-13 22:23:55 +02:00
Psychotoxical 01f71cf50c chore(release): bump version to 1.34.10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:21:07 +02:00
Psychotoxical 39966d1c65 Create FUNDING.yml 2026-04-13 22:07:56 +02:00
Psychotoxical cbf72c7eba chore(about): add cucadmuh PR #176 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:54:02 +02:00
cucadmuh 4207455440 fix(audio): Linux output device selection and watcher (#176)
* fix(audio): stabilize Linux output device picker and watcher

Keep pinned ALSA/cpal device ids stable when enumeration omits the active
sink or returns an equivalent name. Stop Linux device-watcher from clearing
the pin based on missing list entries; macOS and Windows still treat repeated
absence as unplugged. Settings refresh flow calls canonicalize and refetches
the list; add i18n for the out-of-list device label.

* fix(settings): sort audio output devices by label

cpal enumeration order is arbitrary; order the dropdown by readable label
and place the current OS default device first among concrete outputs.
2026-04-13 21:48:21 +02:00
Psychotoxical 9983d13122 chore(about): add kilyabin PR #175 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:48:13 +02:00
kilyabin 1b55e88cd5 fix(fullscreen-player): stop mesh blob and portrait animations in no-compositing mode (#175)
In software compositing (WEBKIT_DISABLE_COMPOSITING_MODE=1), WebKit renders
entirely on the CPU. The mesh blob animations were not covered by the
no-compositing overrides: blob-a occupies 130×130% of the viewport, so each
frame the CPU had to composite a surface larger than the screen. On high-DPI
displays (e.g. 8K) this drops the fullscreen player to ~10 FPS.

- Stop mesh blob pan animations (static gradients are preserved)
- Stop portrait drift animation (filter was already removed)
- Remove box-shadow from the seekbar played bar (its width changes on every
  playback tick; in software mode each change triggers a box-shadow repaint)
2026-04-13 20:46:56 +02:00
Psychotoxical 2326f1f94b feat(linux): AppImage bundle + force X11/XWayland on all Linux packages
- CI: add squashfs-tools dep, APPIMAGE_EXTRACT_AND_RUN=1, appimage to
  --bundles, and *.AppImage to upload glob
- main.rs: set GDK_BACKEND=x11 and WEBKIT_DISABLE_COMPOSITING_MODE=1
  on Linux before Tauri init — WebKitGTK on Wayland is unstable;
  both vars are overridable by setting them before launch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:45:00 +02:00
Psychotoxical 27ea221130 chore(about): update contributors, remove feature tagline, limit changelog to 3
- Add missing contributions: cucadmuh (PR #144, #167, #173, #174),
  kveld9 (PR #168), nisarg-78 (PR #115)
- Remove aboutFeatures tag line from all 8 locales (too dynamic to maintain)
- Changelog section now shows only the 3 most recent versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:23:22 +02:00
cucadmuh 9cd4743d1c feat(settings): audio output device picker (labels, OS default, live refresh) (#173)
* feat(settings): clearer audio device labels for duplicate ALSA names

Show HDMI outputs as "Card (HDMI n)" from hdmi:DEV indices; include PCM and
optional subdevice for hw/plughw/sysdefault; label other ALSA plugins with
iface and PCM. When labels still collide, append a structured hint
(iface · card · PCM) instead of only truncating the raw device string.

* feat(settings): improve audio output device picker

Parse ALSA-style ids into clearer labels (HDMI with DEV index, PCM/subdevice
for hw/plughw/sysdefault). Disambiguate colliding labels; share stderr
suppression for Linux device enumeration.

Add audio_default_output_device_name and tag the matching list entry as the
current system output (i18n). While the Audio tab is open, refresh list and
mark on audio:device-changed and audio:device-reset without toggling the
refresh spinner. Show an error toast if listing devices fails.
2026-04-13 20:13:22 +02:00
cucadmuh e75fda168e fix(folder-browser): arrow navigation only without modifiers (#174)
Skip column/list arrow handling when any modifier is down. Detect modifiers
via nativeEvent and getModifierState (WebKit/WebView), and match arrow keys
by both key and code. On row buttons, preventDefault for plain arrows only
to avoid native focus/scroll stealing navigation. Filter field ArrowDown uses
the same modifier check.
2026-04-13 20:13:10 +02:00
Psychotoxical ec021516c7 fix(audio): device-reset false positive + stderr noise + i18n tooltip
- Device watcher requires 3 consecutive misses (~9 s) before triggering
  audio:device-reset, preventing false positives when ALSA temporarily
  hides a busy device from output_devices() enumeration
- Add stderr suppression to open_stream_for_device_and_rate (last source
  of ALSA terminal noise on Linux)
- Fix tooltip showing raw key 'common.refresh' — replaced with
  settings.audioOutputDeviceRefresh (added to all 8 locales)
- Device switch restarts track from beginning (no seek-back)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:00:36 +02:00
Psychotoxical 82e1b458f3 fix(audio): clean up ALSA device names for display on Linux
Raw ALSA names like "sysdefault:CARD=U192k" are replaced with
readable labels ("U192k") in the device dropdown. The underlying
value stays unchanged so rodio can still open the correct device.

Covers: sysdefault, hw, plughw, iec958, front, surround prefixes.
Names without ALSA structure (pipewire, pulse, default) are kept as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:46:53 +02:00
Psychotoxical fc7a5815ef feat(audio): add refresh button for device list in Settings
The module-level cache prevented newly connected USB audio devices
from appearing in the dropdown. Replace with a manual refresh button
(RotateCcw icon, spins while loading) next to the dropdown.

Device enumeration now runs on every Audio tab open and on explicit
refresh. ALSA stderr noise is already suppressed by the dup2 guard,
so re-enumeration is silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:38:58 +02:00
Psychotoxical 1cf5faff21 fix(audio): suppress ALSA stderr noise during device enumeration
Device watcher (3 s loop) and audio_list_devices both called
cpal output_devices(), triggering ALSA to probe unavailable backends
(JACK, OSS, dmix) and spam stderr with error messages.

Fix: redirect fd 2 to /dev/null for the duration of each enumeration
on Unix via libc dup/dup2 + RAII guard. Also add a module-level
cache in Settings.tsx so audio_list_devices is only invoked once
per app session instead of on every Audio tab activation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:30:24 +02:00
Psychotoxical 16cb4f5a6f feat(audio): audio output device selection (closes #169)
Adds the ability to choose which audio output device Psysonic plays
through — useful for USB DACs, dedicated soundcards, and multi-device setups.

Rust (audio.rs):
- open_stream_for_device_and_rate(device_name, rate): opens a named
  device by cpal name, falls back to system default if not found
- AudioEngine.selected_device: persists the chosen device across
  stream reopens so hi-res rate switches stay on the right device
- audio_list_devices: Tauri command — returns all output device names
- audio_set_device: Tauri command — switches device immediately,
  drops old sinks, emits audio:device-changed
- start_device_watcher: now handles two cases:
    1. No pinned device + system default changed → reopen on new default
    2. Pinned device disappeared (DAC unplugged) → clear selected_device,
       fall back to system default, emit audio:device-reset

Frontend:
- authStore: audioOutputDevice (string | null), persisted
- playerStore: applies stored device on cold start
- App.tsx: listens to audio:device-reset, clears authStore device
  and restarts playback on the fallback device
- Settings → Audio tab: device dropdown at top (above Hi-Res and EQ),
  uses CustomSelect (portal-based, styled), all 8 locales
- CustomSelect: added disabled prop

Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out
of scope for now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:20:23 +02:00
Psychotoxical 9b22327bb0 fix(login): clarify that https:// URLs are accepted in server URL field (#171)
https:// was already supported by the code (startsWith('http') check) but
the placeholder text only showed bare host:port examples, giving no hint
that a full URL with protocol is valid.

- Updated serverUrlPlaceholder in login namespace (all 8 locales) to show
  https://music.example.com as the domain example
- Added settings.serverUrlPlaceholder i18n key (all 8 locales) and wired it
  into AddServerForm — previously the placeholder was a hardcoded English string

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:59:19 +02:00
Psychotoxical dba89cc4e4 fix(offline): replace blocking overlay with slim banner; add server settings link (#170)
When offline with no cached content, the full-screen OfflineOverlay blocked
all navigation including Settings, making it impossible to fix a broken
server config. Replace it with the same slim banner used in offline-cache mode.

- OfflineBanner now handles both cases: cache (existing) and no-cache (new)
- No-cache banner shows server name and a direct link to Settings → Server tab
- OfflineOverlay component is no longer used (import removed from App.tsx)
- All 8 locales: added offlineNoCacheBanner and serverSettings keys

Fixes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:52:17 +02:00
Psychotoxical 91d4cb58b8 feat: playlist management enhancements & UX improvements (#168)
- Multi-selection for Albums, Artists, Playlists with context menu bulk-add
- Collapsible playlist section in sidebar
- Infinite scroll on Artists page (IntersectionObserver)
- Submenu flip-up on viewport overflow
- Remove from Playlist in context menu
- All 8 locales synced

Fixes applied:
- title= → data-tooltip on playlist toggle button (CLAUDE.md)
- Hardcoded Spanish aria-label → i18n (sidebar.expandPlaylists/collapsePlaylists)
- AddToPlaylistSubmenu fetches playlists on first open if store empty

Co-Authored-By: kveld9 <kveld9@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:37:50 +02:00
Psychotoxical 374ebce9b5 feat(keybindings): in-app modifier chords; fix seek shortcut units (#167)
feat(keybindings): in-app modifier chords; fix seek shortcut units
2026-04-13 17:35:45 +02:00
Psychotoxical 083c5f62cb fix(pr168): title→data-tooltip, i18n playlist toggle, cold-start playlist fetch
- Replace title= with data-tooltip on sidebar playlist expand button (CLAUDE.md)
- Replace hardcoded Spanish aria-label strings with i18n keys (sidebar.expandPlaylists / collapsePlaylists) across all 8 locales
- AddToPlaylistSubmenu now fetches playlists on first open if store is empty (regression fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:33:56 +02:00
kveld9 592a479c30 fixed some bugs 2026-04-12 20:43:31 -03:00
Maxim Isaev 6439200e95 feat(keybindings): in-app modifier chords; fix seek shortcut units
Persist chords as ctrl/alt/shift/super plus key code; legacy single-key
bindings still match without modifiers. Settings capture uses buildInAppBinding;
App uses matchInAppBinding and skips chords also registered as global shortcuts.
Share MODIFIER_KEY_CODES and formatBinding with global shortcut formatting.

Fix seek-forward/backward hotkeys: seek() expects 0-1 progress, not seconds.
2026-04-13 01:01:50 +03:00
kveld9 9cb1471170 chore: apply stashed changes 2026-04-12 17:55:13 -03:00
kveld9 21e26e2604 chore: remove tauri generated schemas from tracking 2026-04-12 17:54:16 -03:00
kveld9 94323e91fa fixed bugs 2026-04-12 17:53:52 -03:00
kveld9 bef6941a2b feat: add multi-selection and context menu for playlist management 2026-04-12 17:53:52 -03:00
Psychotoxical 805b6bf163 chore(gen): update Tauri schema files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:51 +02:00
Psychotoxical fee454da5d fix(folder-browser): correct --bg-base to --bg-app in filter bar; add cucadmuh PR #165
--bg-base is undefined — sticky filter bar would have rendered transparent.
Also added PR #165 to cucadmuh's contributions in Settings About.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:40 +02:00
Psychotoxical a765bcd5d0 feat(themes): add Vision Dark & Vision Navy colorblind-safe themes (closes #166)
Purple & Gold palette (Deuteranopia / Protanopia / Tritanopia safe).
Vision Dark: near-black #0D0B12 + Gold #FFD700 (~14.7:1 WCAG AAA).
Vision Navy: deep navy #0A1628 + Gold #FFD700 (~14.5:1 WCAG AAA).
New 'Accessibility' group in ThemePicker.
Removed stale theme count from aboutFeatures across all locales.

This is an initial step toward colorblind accessibility — color variables
alone cannot cover all CVD use cases. Structural improvements (secondary
indicators, pattern/shape cues) are still needed in future iterations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:29 +02:00
Psychotoxical 7e50c7af40 feat(folder-browser): per-column filter keyboard flow
feat(folder-browser): per-column filter keyboard flow
2026-04-12 18:41:08 +02:00
Maxim Isaev a12d6a4015 feat(folder-browser): add per-column filter and queue append hotkey flow
Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes.

When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue.
2026-04-12 14:08:36 +03:00
Psychotoxical da1cc91ff1 fix(deps): bump @tauri-apps/plugin-fs to ^2.5.0 and plugin-dialog to ^2.7.0
Rust crates resolved to newer minor versions; npm pins were behind,
causing CI version-mismatch error on tauri build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:49:07 +02:00
Psychotoxical 08235e1aa5 chore(aur): bump PKGBUILD to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:46:45 +02:00
Psychotoxical 57dbd50092 docs: add CHANGELOG for v1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:44:08 +02:00
Psychotoxical 4a2baaf87d chore: bump version to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:38:48 +02:00
Psychotoxical 65e748c464 chore(settings): add cucadmuh PR #163 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:35:02 +02:00
Psychotoxical 346fe604d9 feat(contextMenu): add Trash2 icon to Remove + Artist öffnen unter Album öffnen
- "Diesen Song entfernen" bekommt Trash2-Icon
- Neuer Eintrag "Künstler öffnen" (goToArtist) nach "Album öffnen" in beiden Song-Kontextmenüs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
Psychotoxical 600af0e527 feat: bulk multi-select + drag in PlaylistDetail, Favorites, and artist context menu (#157)
- PlaylistDetail: Ctrl/Cmd+Click enters select mode; bulk drag emits
  { type: 'songs', tracks } when ≥2 selected; filtered-view rows now
  also draggable as single songs
- Favorites songs: full multi-select system — Ctrl+Click, Shift+Click,
  header toggle-all checkbox, bulk-selected highlight, bulk drag to
  queue, bulk-bar with Add to Playlist + Clear
- ContextMenu: ArtistToPlaylistSubmenu resolves all artist album songs
  and forwards to AddToPlaylistSubmenu
- Locale: common.clearSelection + playlists.addSelected in all 7 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
cucadmuh 2dd993257a feat(player): build infinite queue using instant-mix strategy (#163)
Prefer artist-driven candidates (Top + Similar) when extending infinite queue, with random songs as a fallback when mix sources are empty.
2026-04-12 12:34:27 +02:00
Psychotoxical c2f7d6d495 chore(about): add missing contributor entries for v1.34.4 PRs
kilyabin: PR #148 (ru locale), #155 (Build a Mix hub), #156 (FS player perf + settings)
cucadmuh: PR #158 (Folder Browser keyboard nav + context menus)
kveld9: new contributor — PR #159 (Spanish translation), #160 (column sorting)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:11:17 +02:00
Psychotoxical 6a8422ad1f fix(artists): add hover effect to alphabet filter buttons
Buttons were built with inline styles — no :hover possible. Replaced with
.artists-alpha-btn CSS class: accent-colored hover with subtle glow ring,
active state unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:52:31 +02:00
Psychotoxical 8f45f7230e fix(statistics): accurate counts for large libraries + album-artist tooltip
Remove the 10-page cap in fetchStatisticsLibraryAggregates — the loop now runs until
the server returns a partial page, so albums/songs/playtime reflect the full library
regardless of size (previously capped at 5,000 albums). Switched sort type from
'newest' to 'alphabeticalByName' for a stable pagination order.

Add a tooltip on the Artists stat card explaining it shows album artists only (Subsonic
API limitation — track-level featured/guest artists without their own album are not
included). Tooltip added in all 8 locales. Labels with a tooltip get a dotted underline
and cursor:help as visual hint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:47:41 +02:00
cucadmuh ae2e1bcb97 feat(folder-browser): keyboard nav, context menus, now-playing path UX
Adds full keyboard navigation (arrow keys, Enter, Ctrl+Enter for context menu) across Folder Browser columns. Context menus for all row types with keyboard-operable submenus and star-rating control via arrow keys + Enter. Now-playing path is visually emphasized and stays stable; rebuilds on hotkey re-invoke or active-path follow-along. Adaptive column layout for deep trees with right-side visibility priority. New configurable 'Open Folder Browser' keybinding. StarRating animation sync for keyboard-driven changes. All 7 locales updated.
2026-04-12 11:27:26 +02:00
kilyabin 3d07a877f2 feat(fullscreen): performance fixes + appearance settings
Adds no_compositing_mode Tauri command; frontend adds html.no-compositing on Linux to replace GPU-only CSS effects (backdrop-filter, filter, mask-image) with software-friendly equivalents.

Settings → Appearance → Fullscreen Player: toggle for artist portrait visibility + 0–80% dimming slider.

Fix: long words in lyric lines now wrap correctly.
2026-04-12 11:26:44 +02:00
kilyabin bf38a286cd feat(nav): merge Random Mix & Albums into Build a Mix hub
Replaces two sidebar entries (Random Mix, Random Albums) with a single 'Build a Mix' item (Wand2 icon) at /random. Landing page shows two cards — Mix by Tracks and Mix by Albums — with glow-on-hover. Old routes updated throughout. All 7 locales updated.
2026-04-12 11:26:27 +02:00
Kveld. 8f18f73b33 feat(i18n): add Spanish (es) translation
Adds complete Spanish locale with 964 translated strings. Registered in i18n.ts, added to language dropdown in Settings. 8 languages total.
2026-04-12 11:25:54 +02:00
Kveld. 1a2352009b feat(tracklist): column-header sorting for albums & playlists
Replaces dedicated sort buttons with clickable column headers in album and playlist detail views. Three-click cycle: asc → desc → natural order. Sortable by title, artist, album, favorite, rating, duration. Active column shown bold with ▲/▼ indicator.
2026-04-12 11:25:40 +02:00
Psychotoxical eedf6f9337 Update CHANGELOG.md 2026-04-12 11:08:12 +02:00
Psychotoxical b9b8f3fc15 feat(tracklist): multi-select + psyDnD, filter/sort, settings & UI polish
- AlbumTrackList: extract TrackRow as React.memo, selection state moved to
  selectionStore (Zustand) for O(1) re-renders per toggle; Ctrl/Cmd+Click
  enters select mode; drag selected tracks as {type:'songs'} payload;
  selection clears on outside click or song-list change
- QueuePanel: handle 'songs' multi-track drop type; whitelist drag types to
  suppress drop feedback for non-queue drags (lyrics grip etc.)
- AlbumDetail + PlaylistDetail: filter/sort toolbar (title/artist, natural
  order); disc grouping bypassed when sorted; playlist reorder DnD disabled
  while filter active
- useTracklistColumns: 'known' field auto-shows newly added columns for
  existing users
- PlayerBar: mute/unmute restores previous volume via premuteVolumeRef
  instead of hardcoded 0.7
- Settings/Input: reset buttons restyled as RotateCcw icon above card,
  matching HomeCustomizer layout
- i18n: filterSongs, sortNatural, sortByTitle, sortByArtist keys across
  all 7 locales
- components.css: album-card-title/artist nowrap to keep playlist grid
  cards uniform height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 11:42:47 +02:00
Psychotoxical b46acc88e3 fix(player): hot playback cache — minor follow-up (#153)
fix(player): hot playback cache — minor follow-up
2026-04-11 10:32:57 +02:00
Maxim Isaev b4c3124168 fix(player): minor hot-cache eviction, prefetch budget, and settings live size
Small fix for hot playback cache: eviction keeps current and next only;
prefetch up to five tracks when under cap, always fetch the immediate next;
grace for the previous current until debounce; run evict immediately on
MB or folder changes; re-read cap after download; optional Track.size;
live disk usage on Audio settings.
2026-04-11 03:29:59 +03:00
Psychotoxical 20dabbfd03 feat(lyrics): configurable sources with drag-to-reorder + feat(audio): ReplayGain pre-gain & fallback
Lyrics Sources:
- Replace lyricsServerFirst + enableNeteaselyrics toggles with a new
  Settings section — three sources (Server, LRCLIB, Netease) each
  individually toggled and drag-reorderable via psy-drop DnD
- useLyrics.ts iterates sources in user-defined order, skipping disabled
  ones; embedded SYLT from local files still wins unconditionally
- onRehydrateStorage migration from legacy fields on first load

ReplayGain Pre-Gain:
- New authStore fields: replayGainPreGainDb (0…+6 dB) and
  replayGainFallbackDb (-6…0 dB, untagged / radio)
- audio.rs compute_gain applies pre_gain_db and fallback_db
- Settings sliders under ReplayGain mode selector
- Radio HTML5 volume scaled by fallbackDb factor on playRadio

i18n(ru): incorporate PR #148 translation improvements (kilyabin)
All 7 locales updated for new keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:57:36 +02:00
Psychotoxical 003e7a3203 Merge pull request #148 from kilyabin/lang-ru-fix
i18n(ru): improvement of some translation strings
2026-04-10 23:56:10 +02:00
Psychotoxical c142e9e983 feat(radio): PLS/M3U playlist resolution + ICY metadata for playlist streams
Resolves PLS and M3U/M3U8 playlist URLs to their first direct stream URL
before playback and ICY metadata fetching. Stations configured with a
.pls or .m3u URL (e.g. SomaFM, schizoid.in) now play correctly and
report track metadata via ICY headers.

- Rust: parse_pls_stream_url / parse_m3u_stream_url helpers
- Rust: resolve_playlist_url (shared) + resolve_stream_url Tauri command
- fetch_icy_metadata: auto-resolves playlist URLs before connecting
- playerStore: playRadio() awaits resolve_stream_url before setting audio src

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:18:53 +02:00
Psychotoxical 549677ffd4 feat(random-mix): context-aware remix button + genre reset chip
Remix button now targets the active context: relabels to "{{genre}} neu
mixen" when a genre is selected and re-fetches that genre instead of the
global random pool. "Alle Songs" chip added as first genre option to
reset back to the full library mix without leaving the page.

Also fixes album-grid cards stretching to full page height on macOS
WebKit (align-items: start on .album-grid-wrap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:19:22 +02:00
Psychotoxical dcc3e52ad1 fix(store): reset conflicting hot cache + preload state on rehydration
Users who had both hotCacheEnabled and preloadMode !== 'off' before
mutual exclusion was enforced will have both reset to off on next start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:52 +02:00
Psychotoxical 4b6888049f fix(audio): properly suppress unused-variable warning for DecodeError msg
Use `let _ = msg` under `#[cfg(not(debug_assertions))]` so the binding
remains available in debug builds for the eprintln! format string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:34:55 +02:00
Psychotoxical f45892e975 chore(deps): fix npm audit vulnerabilities (axios, vite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:29:02 +02:00
Psychotoxical 16c511c167 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:40 +02:00
kilyabin 9986149e2c i18n(ru): improvement of some translation strings 2026-04-10 19:25:52 +04:00
Psychotoxical 31f72b0459 Update CHANGELOG.md 2026-04-10 17:18:55 +02:00
Psychotoxical e060737de9 chore(aur): bump PKGBUILD to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:15:54 +02:00
Psychotoxical 28b23a9de1 docs(changelog): add v1.34.8 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:13:40 +02:00
Psychotoxical 649f5223b4 chore: bump version to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:06:19 +02:00
Psychotoxical 1cc674e4ee fix(ui): icon updates, remove mic button, theme fixes
- LiveSearch: replace SlidersVertical with TextSearch for advanced search
- AlbumHeader: add Highlighter icon for artist bio button
- PlayerBar: remove unused lyrics/mic button
- components.css: album-detail-badge always opaque (accent bg, white text)
- theme: Middle Earth — remove sidebar stripes, fix queue/artist/bio contrast
- theme: Toy Tale — fix muted text, queue tabs, sidebar labels, divider
- theme: Tetrastack — brighten purple/blue palette, raise text-muted contrast
- theme: Horde & Alliance — remove repeating sidebar line pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:05:20 +02:00
Psychotoxical 41e98d5783 fix(lyrics): strip Netease metadata lines from LRC output
Filter lines matching 作词/作曲/编曲/制作人/etc. that Netease
embeds as timestamped LRC entries at the start of the song.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:13:35 +02:00
Psychotoxical 6ffcd6f6fa feat(lyrics): add Netease Cloud Music as opt-in fallback source
Netease is queried only when server and LRCLIB both return nothing,
preserving the existing lyrics chain completely. Off by default.

- Rust command `fetch_netease_lyrics` proxies Netease API (CORS bypass)
- `src/api/netease.ts` TypeScript wrapper via invoke
- `authStore.enableNeteaselyrics` toggle (default: false)
- `useLyrics`: Netease fires last in fallback chain when enabled
- Settings toggle in the Lyrics section
- `lyricsSourceNetease` label in LyricsPane
- i18n: all 7 languages (en, de, fr, nl, nb, ru, zh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:11:06 +02:00
Psychotoxical 33a15fd17a chore(settings): add AudioMuse-AI PR #147 to cucadmuh's contributions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:51:14 +02:00
Psychotoxical 084543e59b fix(tracklist): split multi-artist tracks and fix reset button style
- Use OpenSubsonic `artists[]` array to render each artist separately
  with · separator; artists with an ID are clickable, others plain text
- Fall back to single artist (artistId/artist) on non-OpenSubsonic servers
- Settings reset-to-defaults buttons changed from btn-ghost to btn-danger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:37:29 +02:00
Psychotoxical b0081e3d7a Merge pull request #147 from cucadmuh/feat/audiomuse-navidrome
feat(discovery): Navidrome AudioMuse-AI (Instant Mix, similar artists, probe)
2026-04-10 14:09:21 +02:00
Psychotoxical ccc9f2cae5 Merge pull request #147: feat(discovery): Navidrome AudioMuse-AI integration
Resolves conflict in ContextMenu.tsx: keep useShallow import from main
alongside getSimilarSongs added by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:09:13 +02:00
Psychotoxical f34bd7c0f1 fix(audio): separate gapless chain from preload gate and reduce black flash
- Gapless now always chains at 30s regardless of preloadMode ('off' no
  longer breaks gapless playback)
- Byte pre-download (audio_preload) skips when Hot Cache is active to
  avoid duplicate downloads
- When both Gapless and Preload are active, bytes are pre-fetched early
  and the chain reuses the cached data (separate bytePreloadingId guard)
- player-album-art-wrap gets background: var(--bg-card) so the brief
  opacity-0 loading state shows a themed colour instead of black

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:04:30 +02:00
Psychotoxical 8cb5eb9384 chore(settings): remove alpha badges from Hot Cache and Hi-Res sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:45:56 +02:00
Psychotoxical 02a4c43f61 docs: add psysonic-bin AUR badge and early-development warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:41:51 +02:00
Psychotoxical c49f6af38b perf: reduce CPU usage and unify next-track buffering settings
- Throttle audio:progress from 100ms to 500ms; WaveformSeek and
  FsSeekbar use imperative DOM updates instead of React re-renders
- Fix all usePlayerStore() calls without selectors across pages and
  components (useShallow / individual selectors throughout)
- Remove filter:blur and transform:scale from Hero, AlbumDetail and
  FullscreenPlayer backgrounds — eliminated expensive software
  compositing layers on WebKitGTK
- Replace translate3d with 2D translate in FS mesh and portrait
  animations; remove will-change:transform from mesh blobs
- Move Hot Cache section from Storage tab to Audio tab; group Preload
  and Hot Cache under a shared 'Next Track Buffering' section with a
  mutual-exclusivity note and auto-disable logic
- Add 'off' toggle to Preload (replaces Off button with toggle switch);
  enabling either method now automatically disables the other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:38:33 +02:00
Maxim Isaev 69c0c5a907 merge: integrate origin/main into feat/audiomuse-navidrome 2026-04-10 13:04:34 +03:00
Maxim Isaev 4de577eede feat(navidrome): Instant Mix probe, ping identity, and AudioMuse UX
Parse type and serverVersion from Subsonic ping; persist per-server identity and
run a background Instant Mix probe (random songs + getSimilarSongs) for
Navidrome 0.60+. Hide the AudioMuse server toggle when the probe returns no
similar tracks; clear prefs when the server is no longer eligible.

Artist pages fall back to Last.fm similar artists when AudioMuse is enabled but
the server returns none. Instant Mix failures set a per-server issue flag with a
settings warning and toast. Settings description links the official plugin repo
via i18n Trans.
2026-04-10 13:02:46 +03:00
Psychotoxical c9dfbcc19f chore(settings): reset uiScale to 1.0 on startup while scaling is disabled
Users who had previously set a custom scale are silently reset to 100%
on next launch so they are not stuck at a broken zoom level. The stored
value is cleared in fontStore so the slider starts at 100% once the
feature is re-enabled.

Also credits nisrael for the ICY / AzuraCast PR #146.

TODO: remove the reset effect and re-enable the slider in Settings.tsx
when UI scaling is properly reworked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:17:43 +02:00
Maxim Isaev 36f3d42dbe feat(discovery): Navidrome AudioMuse-AI client integration and library scoping
Add per-server toggle for AudioMuse-style discovery: Instant Mix via
getSimilarSongs, server similar artists instead of Last.fm on artist pages,
and higher similar-artist count in Now Playing when enabled.

When browsing a single music folder, filter similar/top song results by
album ids from that scope (Navidrome does not apply musicFolderId to those
endpoints). Request larger similar batches under a narrow scope.

Keep radio-from-track as two blocks (shuffled top songs, then shuffled
similar-by-artist) so it differs from Instant Mix. Show Alpha badge beside
the setting. Add research/ to .gitignore.
2026-04-10 12:07:32 +03:00
Psychotoxical 49f7fe5f6e feat: add ICY metadata and AzuraCast radio streaming support (#146)
feat: add ICY metadata and AzuraCast radio streaming support
2026-04-10 11:05:59 +02:00
Psychotoxical 28943f1ecb chore(settings): disable UI scale slider pending rework
Interface scaling is temporarily disabled in the Settings UI
(opacity + pointer-events: none) with a note that it will return
in a future update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:04:21 +02:00
Nils Israel 46cefb5712 feat: add ICY metadata and AzuraCast radio streaming support
Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/88faada5-28bb-446f-b53b-46a0efef387e

Co-authored-by: GitHub Copilot <198982749+copilot@users.noreply.github.com>
Signed-off-by: Nils Israel <nils@sxda.io>
2026-04-10 10:38:59 +02:00
Psychotoxical 74985fe331 fix(audio): fall back to track gain when album gain is missing
In album gain mode, tracks without albumGain tags received no
ReplayGain adjustment at all. Now falls back to trackGain before
defaulting to 0 dB — fixes inconsistent loudness across albums
where only some tracks have albumGain metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:12:06 +02:00
Psychotoxical 4861d99cb1 fix(statistics): library-scoped genre stats, cached fetches, duration i18n
fix(statistics): library-scoped genre stats, cached fetches, duration i18n
2026-04-10 01:11:29 +02:00
Maxim Isaev d2592839b0 i18n(ru): use «Популярное» for most-played nav, home, and page title
«Чаще всего» read awkwardly; «Популярное» matches the play-count sense
without sounding like a grammar exercise.
2026-04-10 02:06:24 +03:00
Maxim Isaev bbeb2f661e merge: integrate origin/main into fix/statistics-i18n-small-fixes 2026-04-10 01:42:14 +03:00
Psychotoxical 5f0fb5dcbd feat(audio): auto-switch to new audio output device at runtime
Adds a background device-watcher that polls the OS default output device
every 3 s via CPAL. When the device changes (Bluetooth headphones connecting,
USB DAC plugging in, etc.) the stream is reopened on the new device, the old
Sink is dropped (it was bound to the now-closed OutputStream), and
audio:device-changed is emitted to the frontend.

Frontend handler (TauriEventBridge): if playing, restarts the current track
from the saved position; if paused, clears the warm-pause flag so the next
resume uses the cold path (audio_play + seek) which creates a new Sink on
the new device.

Fixes #143 (audio through speakers after connecting Bluetooth headphones).
Also covers the intermittently reported single-channel output after long idle,
which can be caused by the OS reconfiguring the audio device in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:40:12 +02:00
Maxim Isaev 6f6cb0fd6b fix(statistics): cache Subsonic stat fetches and localize duration units
Add 7-minute TTL caches (same idea as rating prefetch) for overview
album strips, random-song format sample, and paginated library
aggregates; key by server and music folder.

Introduce formatHumanHoursMinutes with common.duration* strings in all
locales for Statistics and playlist duration labels.

Refresh RU/ZH statistics and nav wording; fix zh playtime label.
2026-04-10 01:39:08 +03:00
Psychotoxical 15dc970f53 fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon
- Revert _msg → msg in DecodeError match arm; the variable is used in the
  debug_assertions eprintln! block so the underscore prefix caused a compile error
- Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar,
  LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:28:12 +02:00
Maxim Isaev fc40d235d0 fix(statistics): scope genre insights to selected music library
getGenres() ignores musicFolderId. Derive genre counts from the same
paginated getAlbumList('newest') scan used for playtime and totals,
using each album's genre and songCount.
2026-04-10 01:18:44 +03:00
Psychotoxical f304589ea1 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:15:55 +02:00
Psychotoxical 22f7de45e8 fix(audio): set User-Agent header, add debug fetch logging
Set psysonic/<version> as User-Agent on the audio reqwest::Client to
prevent reverse proxies (nginx, Caddy, Traefik) from blocking requests
with a 403. Add #[cfg(debug_assertions)] logging in fetch_data to show
the exact URL, status, content-type and server header — useful for
diagnosing proxy-related 403s reported by users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:52:02 +02:00
Psychotoxical 05da369aad chore(aur): bump pkgver to 1.34.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:29:11 +02:00
Psychotoxical 9671f89a48 chore: bump version to 1.34.7, add changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:28:54 +02:00
Psychotoxical 9567a94dea feat(windows): replace GDI taskbar icons with embedded .ico assets
Strips the legacy monochrome GDI drawing code and loads high-quality
icons from embedded .ico files via CreateIconFromResourceEx. Fixes
windows 0.58 import paths (Controls→Shell subclassing, TaskbarList CLSID)
and adds proper cleanup for all four HICONs on WM_NCDESTROY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:14:21 +02:00
Psychotoxical 1ba8619a37 chore: remove preview-update debug button, add GDI features, add contributor
- Remove debug "Preview Update Modal" button from Settings/About
- Add Win32_Graphics/Win32_Graphics_Gdi features for taskbar GDI icons
- Add sorensiimSalling contributor credit for Russian translation (PR #140)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:30:23 +02:00
kilyabin 973b472c1f fix: not translated lyricsServer* string to RU locale (#140) 2026-04-09 22:24:03 +02:00
Psychotoxical 20bf93c344 feat(windows): taskbar thumbnail toolbar with GDI media icons
Implements ITaskbarList3::ThumbBarAddButtons for Prev/Play-Pause/Next
buttons in the Windows taskbar thumbnail preview. Icons are drawn at
runtime via GDI (no binary assets). WndProc subclass intercepts
THBN_CLICKED and emits the same media:* events as souvlaki/tray.
update_taskbar_icon command swaps Play↔Pause icon on state change.
Frontend syncs via playerStore subscribe alongside mpris_set_playback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:12:35 +02:00
Psychotoxical fd834314ba fix(audio): VLC-style frame dropping for corrupt MP3s, silence logs in release
Replace the fixed DECODE_MAX_RETRIES (3) loop with a consecutive-error counter
that tolerates up to 100 bad frames before giving up — matching how VLC handles
files with a handful of invalid main_data offset frames. Frame-drop logs are
wrapped in #[cfg(debug_assertions)] so production builds stay silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:42:38 +02:00
Psychotoxical a78c0fe9ac fix: font switching, layout scaling, embedded lyrics, folder opening
- fix(fonts): update CSS var declarations to @fontsource-variable naming
  ('Inter Variable', 'Outfit Variable', etc.) so dynamic font switching works
- fix(layout): 100vh → 100% on .app-shell to fix Windows WebView2 playerbar drift;
  1fr → minmax(0,1fr) in all grid-template-rows + remove min-height: 720px to fix
  Linux playerbar disappearing at high zoom or small window sizes
- fix(updater): replace shell open() with Rust open_folder command to bypass
  shell:allow-open capability scope blocking local paths on Windows
- fix(lyrics): add get_embedded_lyrics Tauri command (id3 crate for MP3 SYLT/USLT,
  lofty for FLAC SYNCEDLYRICS/LYRICS); fix parseLrc regex for LRC without fractional
  seconds; fix SubsonicStructuredLyrics to accept both synced and issynced fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:52:27 +02:00
Psychotoxical 9c57d4f887 feat(updater): professional update modal with skip, changelog, and OS-aware downloader
Replaces the small corner toast with a centered modal that appears on
startup when a newer GitHub release is detected (4 s delay).

Features:
- Skip this Version: stores skipped tag in localStorage, reappears only
  for newer releases
- Collapsible changelog: renders GitHub release body as markdown accordion
- OS-aware download: Windows → .exe installer, macOS → .dmg (aarch64
  preferred), Linux Arch → AUR hint (yay/pacman), Linux other → .AppImage/.deb
- In-app downloader: Rust download_update command streams to ~/Downloads/,
  emits update:download:progress every 250 ms for a real-time progress bar
- Post-download: Show in Folder button opens Downloads dir via shell.open
- Buttons: Download Now / Skip this Version / Remind me Later

Rust: check_arch_linux() reads /etc/arch-release + /etc/os-release
platform.ts: adds IS_MACOS, IS_WINDOWS alongside existing IS_LINUX
Settings About: Preview Update Modal button for testing
Fixes: renderInline regex had nested capture group causing undefined entries
in split() result → TypeError → React crash → WebKit white-screen freeze

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 5d067b1f8b fix(app): hide resize grips in native fullscreen on all platforms
Previously onResized only tracked fullscreen on Linux. Now all platforms
set isWindowFullscreen, and an initial check on mount catches windows
that start maximized/fullscreened. CSS: .app-shell[data-fullscreen] .resizer
{ display: none } added in layout.css (shipped with updater commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 48d0145dc8 fix(albums): align year filter inputs to button height and font size
Input padding and font-size now match adjacent .btn elements so the
year filter row no longer has mixed heights.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 0da92c2fa1 fix(radio): prevent macOS WKWebView crash + shuffle Artist Radio queue
- Remove currentTime from Zustand persist partialize: it caused 100ms
  localStorage writes that grew with the queue, crashing WKWebView SQLite
  after ~10 min of Artist Radio on macOS
- Trim old played radio tracks from queue (keep last 5) to cap localStorage
  payload size during proactive radio top-up in next()
- Add reconnect logic for internet radio stall events (max 5 retries)
- Shuffle initial Artist Radio queue via Fisher-Yates so positions 2+
  are drawn from similar-artist tracks, not predictable top-5 tracks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 4e3efa3814 feat: self-host fonts via @fontsource-variable, remove Google Fonts CDN
Replaces all Google Fonts CDN references (index.html preconnect/stylesheet
and theme.css @import) with local @fontsource-variable npm packages.
All 10 UI fonts now bundle as WOFF2 into dist/assets/ — app renders
correctly without any internet connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 77085a544e chore(credits): add missing contributor PRs to Settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 8ffecb4b7c Merge pull request #138 from cucadmuh/fix/statistics-music-folder-totals
fix(statistics): scope album and song totals to selected music library
2026-04-09 12:48:02 +02:00
Maxim Isaev c569ff5f34 fix(statistics): scope album and song totals to selected music library
getGenres() is not musicFolder-scoped, so summing genre counts showed
global totals while other statistics respected the library filter.
Derive album and track counts from the same paginated getAlbumList
pass used for playtime, with the same 5000-album cap and ≥ prefix when
capped.
2026-04-09 00:59:16 +03:00
Psychotoxical d16a99a6f9 docs(help): add 11 new FAQ entries for undocumented features
Ratings (q37-q39): how to rate songs/albums/artists, click active star to
remove rating, Skip-to-1★ and rating filter for mixes. Folder Browser (q40).
Settings: Theme Scheduler (q41), UI Scale (q42), Seekbar styles (q43),
AutoEQ (q44), Replay Gain (q45), Hot Cache (q46). Offline: playlist
caching and toggle (q47). All 7 locales updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:52:36 +02:00
Psychotoxical 9b239892ef chore: bump version to 1.34.6, add changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:36:59 +02:00
Psychotoxical ba670bd1e8 feat: fix UI freezes in ZIP and offline cache downloads
ZIP downloads (PlaylistDetail, Albums, NewReleases, RandomAlbums) now use
invoke('download_zip') via Rust streaming instead of fetch+blob+arrayBuffer,
eliminating JS heap saturation on large files. Progress shown in ZipDownloadOverlay.

Offline cache downloads (600+ songs) no longer freeze the UI: transient job
state moved to a separate non-persisted offlineJobStore, reducing localStorage
writes from ~1200 down to 2 for an entire download. Also adds playlist offline
toggle — clicking the cache button when already cached removes it from cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:33:23 +02:00
Psychotoxical c1e57b4c06 fix(home): recently added albums title links to /new-releases instead of /albums 2026-04-08 18:56:39 +02:00
Psychotoxical fbe68116cc chore(aur): bump pkgver to 1.34.5 2026-04-08 18:05:26 +02:00
Psychotoxical dba0c26480 docs(changelog): add v1.34.5 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 3643a78cd6 perf: drastically reduce background API requests to Navidrome (v1.34.5)
- NowPlaying polling: only active while dropdown is open + respects
  Page Visibility API — was firing every 10s unconditionally (~8.6k req/day)
- Connection check interval: 30s → 120s (4× reduction)
- Queue sync debounce: 1.5s → 5s (prevents burst on rapid skip)
- Rating prefetch: add 7-minute in-memory TTL cache for artist/album
  ratings — repeated random album loads no longer re-fetch known ratings

Fixes server log flooding reported by users behind reverse proxies (Traefik).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 1e4b851e9e feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
feat: disable native decorations on Linux, hide custom TitleBar for t…
2026-04-08 17:49:38 +02:00
kilyabin 37799fb861 feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, Openbox, etc.)
     the window has no title bar at all — neither native nor custom. On stacking DEs
     (GNOME, KDE, XFCE) the custom TitleBar can be toggled in settings as before.

     Native decorations are disabled on all Linux at startup. The frontend checks
     is_tiling_wm() to decide whether to render the custom TitleBar:
     - DE (GNOME/KDE): custom TitleBar shown (user-toggleable in settings)
     - Tiling WM: no TitleBar at all, setting hidden from Settings page

     Detection is based on environment variables:
     - HYPRLAND_INSTANCE_SIGNATURE, SWAYSOCK, I3SOCK (direct compositor signatures)
     - XDG_CURRENT_DESKTOP check for known tiling WMs

     Changes:
     - Added is_tiling_wm_cmd() Tauri command for frontend detection
     - Native decorations disabled on all Linux at startup
     - TitleBar not rendered on tiling WMs
     - Custom TitleBar setting hidden in Settings for tiling WMs
2026-04-08 19:36:39 +04:00
Psychotoxical 099516121e perf(fullscreen): fix dynamic accent color delay via direct fetch + cache
- Bypass ImageCache 5-slot queue: fetch cover art directly, create blob URL
  for canvas extraction, revoke immediately after use
- Module-level cache (artKey → accent) avoids re-fetching within the same
  album — same-cover tracks get the color instantly
- Keep previous color visible on cache miss instead of flashing to theme color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:13:48 +02:00
Psychotoxical 9047a44480 perf(fullscreen): extract dynamic accent from already-loaded cover blob
Previously a separate getCachedUrl call fetched the 300px cover art just
for color extraction, racing with the 500px fetch for display. Now reuses
resolvedCoverUrl directly — color appears as soon as the display image is
ready, with no redundant network request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:56:18 +02:00
Psychotoxical 47fcade3b3 feat(settings): show hint in theme picker when scheduler is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:51:43 +02:00
Psychotoxical d832dfb253 fix(statistics): remove Top Rated Songs/Artists sections
The implementation was unreliable (depended on starred songs + session-only
overrides) so the sections are removed entirely. All PR#130 rating logic
(StarRating, setRating API calls, userRatingOverrides) is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:37:12 +02:00
Psychotoxical 041d946b58 feat(settings): 12-hour AM/PM time format for English locale in theme scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:23:46 +02:00
Psychotoxical 64d8b1cbd3 fix(titlebar): window drag, resize grips and mobile-mode grid for custom titlebar
- Add core:window:allow-start-dragging capability so data-tauri-drag-region
  actually works (was silently blocked without this permission)
- Add CSS resize grips (bottom-left + bottom-right) that replace the native
  GTK grip lost when decorations: false
- Add [data-mobile][data-titlebar] grid override so the titlebar renders
  correctly in narrow-window mode instead of being hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:13:58 +02:00
Psychotoxical 5848c621fd feat(folder-browser): Miller columns directory navigation
New page /folders with macOS Finder-style column layout:
- getMusicDirectory / getMusicIndexes / getMusicFolders Subsonic API methods
- Root level uses getIndexes.view (music folder IDs are not getMusicDirectory IDs)
- Columns fill available width with flex: 1, min-width: 200px
- Auto-scroll to rightmost column on expand
- Cancelled-flag prevents stale state on fast navigation
- Clicking a track plays it in the context of the current column's tracks
- FolderOpen/Folder/Music icons, accent-colored selection, ellipsis truncation
- Hidden in sidebar by default (user can enable in Settings)
- i18n: EN DE FR NL NB ZH RU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:40:29 +02:00
Psychotoxical 0a0dde057d feat(seekbar): fade edges on waveform style
Apply a destination-in gradient mask after drawing the waveform bars so
both the left and right edges fade smoothly to transparent instead of
cutting off abruptly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:01 +02:00
Psychotoxical 0119e27f6d feat(covers): logo fallback for broken cover art images
CachedImage now shows /logo-psysonic.png when an image fails to load
instead of a broken icon. The fallback switches to object-fit: contain
with a card background so the logo stays proportional inside the container.

If the caller provides a custom onError (e.g. to hide the element), that
handler is used instead. A separate fallbackSrc state prevents React from
re-writing the broken URL on re-renders. The DOM-level onerror = null
guard prevents any infinite error loop.

extractCoverColors skips color extraction when the logo fallback is
active, falling back to the theme accent color instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:25 +02:00
Psychotoxical c7adb599ee fix(artist): decouple artist info fetch from main render path (fix #132)
getArtistInfo2.view can trigger Navidrome to do slow external lookups
(Last.fm / Apple Music) when no local artist image exists, blocking the
entire page for 10+ seconds.

Render the page immediately after getArtist() resolves (local data only),
then fire getArtistInfo and getTopSongs in the background. A cancelled
flag prevents stale state updates when the user navigates away before
either background fetch completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:10:01 +02:00
Psychotoxical d6546e12ca docs(readme): bring features and roadmap up to date with v1.34.4
- Update language count to 7 (add Norwegian and Russian)
- Update theme count to 67, add Theme Scheduler mention
- Expand seekbar description to all 10 styles
- Add missing features: Ratings, Fullscreen Player, Playlist Management,
  AutoEQ, Internet Radio, Tray Icon, Backup & Restore, UI Scale,
  Album Multiselect, Custom Linux Titlebar
- Add ~15 missing completed items to roadmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:57:25 +02:00
Psychotoxical f0438cae5e fix(seekbar): pulsewave clean connection, retrotape rolling wheel at playhead
Pulsewave: draw flat played line only up to where the Gaussian envelope
starts (startX), eliminating overlap between the flat line and the wave.

Retrotape: replace dual fixed-position reels with a single spinning reel
that tracks the playhead position across the full seek bar width.

fix(i18n): remove literal 'N' from skip-star descriptions in all languages

Replace 'After N skips' with natural phrasing ('after several skips') in
EN, FR, NL, NB, ZH. Simplify ratingsSkipStarThresholdLabel to a single
word (Skips / Sauts / Overslagen / Hopp / 跳过次数). DE was already fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:46:58 +02:00
Psychotoxical e64d151079 feat(settings): theme scheduler, UI scale, CustomSelect scroll fix
- Time-based theme scheduler: auto-switches between day/night theme
  based on configurable times; setInterval re-checks every minute
- UI scale slider (80–150%) via CSS zoom on document root; preset
  buttons aligned to actual slider positions
- CustomSelect: scroll listener keeps dropdown anchored on scroll
- All features i18n in 7 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:23:24 +02:00
Psychotoxical f0b423ddc4 Update CHANGELOG.md 2026-04-08 12:04:37 +02:00
Psychotoxical 8f3b9ebe00 ci: use setup-node built-in npm cache instead of manual cache step 2026-04-08 11:30:05 +02:00
Psychotoxical d4b18fec5a fix(statistics): explicit Map<string, SubsonicSong> type to satisfy tsc 2026-04-08 11:17:05 +02:00
Psychotoxical dbb53bfa70 release: bump to v1.34.4
Song ratings in context menu + player bar, entity ratings (PR #130),
5 new seekbar styles, custom Linux title bar, album multi-select,
mix rating filter, top-rated stats, compilation filter, scroll reset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:13:23 +02:00
Psychotoxical 737b057d4a feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)
Seekbar styles (5 new, by Psychotoxical):
- Neon Glow: transparent track, played section as multi-layer glowing neon tube
- Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF)
- Particle Trail: particles spawn at playhead and drift with glow (rAF)
- Liquid Fill: glass tube fills with liquid and animated wave surface (rAF)
- Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF)

Ratings system (PR #130 by cucadmuh):
- Shared StarRating component with pulse/clear animations, disabled/locked states
- Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport)
- Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server)
- Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home
- Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters
- Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads
- Settings: new Ratings section for skip threshold and per-axis mix filter stars
- i18n: all 7 languages (en, de, fr, nl, zh, nb, ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:29:21 +02:00
Maxim Isaev 0c2ac13fed merge: integrate origin/main into feat/improve-rating-ux
Resolve conflicts in RandomAlbums (keep mix rating filter with batch ZIP/offline
selection from upstream) and Settings (SeekbarPreview + mix filter constants).
2026-04-08 05:04:12 +03:00
Maxim Isaev 1624880c2a feat(ratings): mix cutoff filter for random flows and home, i18n
Apply the same per-axis star cutoff to random mix (multi-batch fetch until
full), random albums, Hero, and Home hero/discover rows. Prefetch artist and
album ratings via Subsonic when list payloads omit them. Exclude items when
0 < rating ≤ threshold; 0 or missing counts as unrated.

Settings: rating filter description interpolates sidebar menu labels; Russian
strings for custom title bar; short axis labels aligned across locales.
2026-04-08 04:57:26 +03:00
Psychotoxical 6587c82e0c fix(backup): include psysonic_home in backup keys
Home section visibility/order is a user preference and was missing
from the backup. Server-specific stores (offline, playlists_recent)
intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:40:54 +02:00
Maxim Isaev 8a1d942128 feat(ratings): skip-to-1★ threshold and mix min-rating filter
Add Ratings section under General: manual skip count before setting 1★,
and per-axis minimum stars for random mixes/albums (song, album, artist).
StarRating shows five stars with selection capped at three; shared max in authStore.
Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types.
2026-04-08 02:39:25 +03:00
Psychotoxical 829936a78d feat(seekbar): configurable seekbar styles with animated preview
Add five seekbar styles selectable in Settings → Appearance:
Waveform, Line & Dot, Bar, Thick Bar, and Segmented. Each option
shows an animated canvas preview using requestAnimationFrame.

Style is persisted in authStore (seekbarStyle, default: waveform).
Translations added for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:39:08 +02:00
Psychotoxical ada7dd010f Revert "feat(waveform): real amplitude waveform via Symphonia + smooth crossfade"
This reverts commit 504c53e71d.
2026-04-08 01:23:36 +02:00
Psychotoxical 67de94d955 chore: update screenshots and fix titlebar window capabilities
- Replace single screenshot with 4 new screenshots in README
- Remove old icon.png, logo.png, screenshot.png
- Add missing allow-minimize + allow-toggle-maximize capabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:06:52 +02:00
Maxim Isaev 705c80ef07 feat(ratings): Subsonic entity ratings and StarRating UX
Probe OpenSubsonic after connect; persist album/artist ratings via setRating when supported. Add shared StarRating (repeat same star to clear, pulse and shrink animations, hover freeze after clear). Widen tracklist duration column and bump narrow saved widths in localStorage. Use StarRating in AlbumHeader, track lists, and playlist rows.
2026-04-08 01:09:17 +03:00
Psychotoxical 504c53e71d feat(waveform): real amplitude waveform via Symphonia + smooth crossfade
Add Rust command `compute_waveform` that downloads the audio file,
seek-samples 500 evenly-spaced frames with Symphonia (fast path) and
computes peak amplitudes. Percentile-based normalisation (p5→p95)
preserves visible dynamics even for heavily compressed music.

Frontend caches results per track (module-level Map), shows the
pseudo-random fallback immediately and crossfades to the real waveform
over 400 ms (ease-in-out) once the computation finishes.

Also: Settings input-tab reset buttons now use btn-danger styling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:15 +02:00
Psychotoxical 5be7f7cabd feat(albums): add multi-select to Albums, New Releases, and Random Albums
Select multiple albums to add them to the offline cache or download
each as a separate ZIP. Action buttons appear inline in the topbar
when at least one album is selected; title shows selection count.
AlbumCard gains selectionMode/selected/onToggleSelect props with a
checkmark overlay and selected outline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:04:44 +02:00
Psychotoxical c1624342d3 fix: scroll content-body to top on route change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:22:51 +02:00
Psychotoxical 4182b67732 feat(most-played): add toggle to filter compilation artists
Toggle button in the Top Artists section header hides known compilation
artist names (Various Artists, VA, Soundtracks, OST, etc.) from the
ranking. Default off. Active state shown in accent color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:20:35 +02:00
Psychotoxical 7b4eeea0dc feat(titlebar): custom Linux title bar with now-playing display (#titlebar)
Replaces the native GTK/GNOME title bar with a built-in one that matches
the app theme on all Linux desktops. Tested on CachyOS + KDE Plasma and
Fedora + GNOME.

- set_decorations(false) on Linux at startup
- TitleBar component: drag region, minimize/maximize/close, centered now-playing
- Hides automatically in native fullscreen (F11) via onResized+isFullscreen
- Optional: toggle in Settings → Verhalten (default: on)
- macOS and Windows completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:08:44 +02:00
Psychotoxical 47cea7e3d6 fix(titlebar): detect fullscreen via onResized+isFullscreen instead of manual state
Manually tracking setIsWindowFullscreen was unreliable (e.g. WM-triggered
fullscreen bypasses the keybinding handler). Now listens to window resize
events and queries actual fullscreen state after each resize.

Tested on CachyOS + KDE Plasma and Fedora + GNOME.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:07:52 +02:00
Psychotoxical 5ed1b58d67 feat(titlebar): hide custom title bar in native fullscreen (F11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:05:31 +02:00
Psychotoxical 900853fedc fix(titlebar): re-focus window after re-enabling native decorations on GTK
GTK re-stacks the window when set_decorations(true) is called, causing it
to lose focus and sink behind other windows. Call set_focus() immediately
after to bring it back to the front.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:01:42 +02:00
Psychotoxical 2c3c89f078 fix(titlebar): add minimize and toggleMaximize window capabilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:50:49 +02:00
Psychotoxical 45fa606ae1 feat(titlebar): make custom title bar optional via Settings toggle
- New authStore field useCustomTitlebar (default: true)
- set_window_decorations Tauri command toggles native decorations at runtime
- Settings toggle visible only on Linux (no restart required)
- i18n keys added to EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:47:29 +02:00
Psychotoxical d49137475f feat(titlebar): custom Linux title bar with now-playing display
- set_decorations(false) on Linux at startup via #[cfg(target_os = "linux")]
- New TitleBar component: drag region + minimize/maximize/close buttons
- Currently playing track (▶/⏸ + artist – title) shown in the center
- app-shell grid gains a 32px titlebar row when data-titlebar is set
- IS_LINUX utility constant via navigator.platform
- macOS and Windows are completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:12:17 +02:00
Psychotoxical dd4e2f1162 docs: add v1.34.3 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:36:05 +02:00
Psychotoxical 78beb699a7 chore: bump version to 1.34.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:36:01 +02:00
Psychotoxical 6f63d7020c fix(radio): add radio-card CSS class to RadioCard component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:35:54 +02:00
Psychotoxical a606d1edd6 feat(themes): add Dracula theme to Open Source Classics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:35:48 +02:00
Psychotoxical 9ef8566d64 fix(discord): add Apple Music cover opt-in, show Paused state
- iTunes Search API calls are now gated behind enableAppleMusicCoversDiscord
  (default: false). No external requests to Apple are made unless the user
  explicitly enables the new opt-in toggle in Settings.
- When the toggle is flipped, Discord presence is immediately re-sent for
  the current track (coversSettingChanged bypasses the early-return guard).
- When paused, activity type switches to Playing (no auto-timer) and state
  text shows "Paused" instead of the artist name.
- New authStore field + setter; new Settings toggle shown only when Discord
  RPC is enabled; i18n keys added to all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:33:16 +02:00
Psychotoxical 4b8b6ae797 feat(most-played): add dedicated Most Played page (#86)
New sidebar item (TrendingUp icon, /most-played route) with:

- Top Artists section: derived by grouping frequent albums by artistId
  and summing playCount — no extra API calls needed. Shows up to 10
  artists as a responsive grid with avatar, rank and total play count.
- Top Albums section: paginated list using getAlbumList('frequent'),
  50 albums per batch with a "Load more" button. Each row shows rank,
  cover thumbnail (44px), album name, artist (navigates to artist
  page), year and play count.
- Sort toggle (most plays first / fewest plays first).

Also adds playCount?: number to SubsonicAlbum — Navidrome returns this
field in getAlbumList2?type=frequent but it was missing from the type.

Closes #86

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:00:07 +02:00
Psychotoxical 9319c40fde feat(fs-player): adaptive dynamic accent color from album cover
Extracts the most vibrant pixel from an 8×8 downscaled album cover via
the Canvas API and applies it as --dynamic-fs-accent on the .fs-player
root element.  All accent-colored FS player elements use
var(--dynamic-fs-accent, var(--accent)) as fallback so the theme accent
is restored automatically when the FS player closes.

Elements updated:
- .fs-track-title (color + text-shadow)
- .fs-btn.active, .fs-btn-heart.active/:hover
- .fs-btn-play and :hover (background + box-shadow)
- .fs-seekbar-played (background + box-shadow)
- .fs-mesh-blob-a/-b (radial-gradient via color-mix at 14%/8% opacity)
- .fs-art-wrap box-shadow glow (color-mix at 70% opacity)
- MicVocal lyrics-toggle inline style

Color safety: WCAG 4.5:1 contrast against near-black FS background
enforced by progressively lightening in HSL space (ensureContrast).

Pure math functions unit-tested with vitest (28 tests, 0 deps beyond
vitest itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:14:20 +02:00
Psychotoxical 63a3bcd0f4 feat(playlists): add ZIP download button to playlist detail page
Mirrors the album download UX: Download (ZIP) button in the playlist
hero header, progress bar while downloading, folder picker via
downloadModalStore (remembers last used folder). Uses the same
Subsonic /rest/download.view endpoint as album downloads — it accepts
playlist IDs just as well as album IDs.

Closes #127

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:00:49 +02:00
Psychotoxical a4c400cc69 fix(audio): correctly parse iTunSMPB gapless info from older iTunes M4A files
M4A files store the iTunSMPB value behind a 16-byte binary 'data' atom
header. The previous parser took bytes directly after the ASCII key,
landing in the binary header and producing a corrupted parts array
(total_valid=348 instead of ~13M samples), which caused take_duration
to exhaust the source in ~8ms — silent no-playback.

Fix: search for the " 00000000 " sentinel within 128 bytes of the key
to locate the actual value string, falling back to the old offset for
ID3v2/MP3 files where no binary header exists.

Also patches symphonia-format-isomp4 (local crate under patches/) to:
- Tolerate SL descriptor predefined=0x01 (older iTunes M4A)
- Convert descriptor.unwrap() panic to a proper decode error
- Gracefully skip malformed trak atoms (e.g. MJPEG cover-art streams)
  instead of failing the entire probe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:54:10 +02:00
Psychotoxical e4aad22c03 chore(aur): bump pkgver to 1.34.2, fix ring comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:59:33 +02:00
Psychotoxical 489d50016f docs: add v1.34.2 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:57:20 +02:00
Psychotoxical 47490072ef chore: bump version to 1.34.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:52:34 +02:00
Psychotoxical ff0a588014 feat(audio): add ALAC and AAC-LC native decoding via Symphonia
Add 'alac' to symphonia features. Combined with the existing 'aac' and
'isomp4' features this enables native lossless decoding of Apple Lossless
Audio Codec (.m4a/ALAC) and AAC-LC files without server-side transcoding.

Symphonia probes the format from the stream bytes, so no URL or hint
changes are needed for server streams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:34:00 +02:00
Psychotoxical e4041287e2 chore(contributors): add cucadmuh PR #125 to contributor list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:00:12 +02:00
Psychotoxical 70f0641439 feat(subsonic): per-server music folder filter and sidebar picker (#125)
Adds library scoping per server: users can select one Navidrome music
folder or 'All' from a new sidebar dropdown. Choice is persisted per
server ID in localStorage. A musicLibraryFilterVersion counter triggers
refetches across browsing pages when the scope changes.

- getMusicFolders() + libraryFilterParams() in subsonic.ts
- New fields in authStore: musicFolders, musicLibraryFilterByServer,
  musicLibraryFilterVersion, setMusicFolders, setMusicLibraryFilter
- Sidebar dropdown via createPortal; collapses to icon in narrow mode
- API scoping applied to: getAlbumList2, getRandomSongs, getArtists,
  getStarred2, search3
- Full i18n coverage (en, de, fr, nl, zh, nb, ru)

Review fixes applied (co-authored):
- Sidebar: title={} → data-tooltip + data-tooltip-pos='right'
- authStore: setMusicFolders merged into single set() call
- Locales: removed dead libraryScopeHint key from all 7 files
- Genres.tsx: removed musicLibraryFilterVersion dep (getGenres unscoped)

Co-authored-by: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:54:57 +02:00
Psychotoxical c779e8f587 fix(pr-review): address review findings for music folder filter
- Sidebar.tsx: title={} → data-tooltip + data-tooltip-pos="right" on
  scope subtitle (CLAUDE.md rule: never use native title= for tooltips)

- authStore.ts: merge two set() calls in setMusicFolders into one atomic
  update — avoids double render on folder list arrival

- locales (all 7): remove dead libraryScopeHint key (defined but never
  rendered in any component)

- Genres.tsx: remove musicLibraryFilterVersion from effect deps —
  getGenres is intentionally not folder-scoped, re-fetching on filter
  change was a no-op waste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:53:44 +02:00
Psychotoxical 95468fb137 refactor: remove Tauri auto-updater, replace with simple GitHub-check modal
Remove tauri-plugin-updater, relaunch_after_update command, generate-update-
manifest.js, the generate-manifest CI job, and all signing/upload steps for
.sig files and latest.json. The updater plugin, its pubkey and endpoint config
in tauri.conf.json, and the updater:default capability are all gone.

Replace AppUpdater.tsx with a lightweight component that:
- Fetches https://api.github.com/repos/Psychotoxical/psysonic/releases/latest
  after a 4 s delay (no install, no download, no progress bar)
- Shows a dismissible toast with two buttons:
  GitHub → releases/latest
  Website → https://psysonic.psychotoxic.eu/#downloads

i18n: remove updaterInstall/Downloading/Installing/Download/ExperimentalHint
keys from all 7 locales; add updaterWebsite; update updaterVersion wording.

CI: remove generate-manifest job and all .sig signing/upload steps from
build-macos-windows. Also remove TAURI_SIGNING_PRIVATE_KEY env vars (no
longer needed). Release now just builds and uploads the installable binaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:42:52 +02:00
Maxim Isaev c36b1ea538 fix(ui): dismiss tooltip on mousedown; hide library dropdown hint while open
TooltipPortal clears the overlay when mousedown hits any [data-tooltip] anchor so click-open controls (e.g. library scope) do not leave a stale hover tooltip. The library scope trigger omits data-tooltip while the dropdown is open.
2026-04-07 14:27:47 +03:00
Psychotoxical 3f1b6fd92d fix(audio): restore device-default rate when Hi-Res is toggled off
The previous optimization skipped all rate-switch logic in standard mode,
but left the stream at e.g. 88.2 kHz if Hi-Res was toggled off mid-session.

Fix: store device_default_rate (from cold-start open) in AudioEngine.
In audio_play, compute target_rate = hi_res ? output_rate : device_default_rate
and reopen only when target differs from current — no-op cost when already
at the right rate (the common case in pure standard mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:18:33 +02:00
Psychotoxical 1c2aa79e29 perf(audio): standard mode resource optimization — lower CPU at 44.1kHz
Stream switch: skip entirely when hi_res_enabled=false. Previously the
engine compared 44100 vs. device-default 48000 Hz and re-opened the device
on every track start — unnecessary IPC + PipeWire reconfigure in safe mode.

MSS buffer: 512 KB in standard mode (was 4 MB). For in-memory MP3/AAC
the large buffer caused eager allocation competing with playback startup.
4 MB is kept only for hi-res (high-bitrate FLAC benefits from fewer reads).

ThreadPriority: no longer set Max at audio-stream-thread startup. Max is
now applied only when a hi-res reopen is requested, keeping the scheduler
budget for the actual decode threads during standard playback.

audio_preload: 8 s throttle delay + gen guard so background pre-fetch does
not compete with the decode/sink-feed cycle of the just-started track.

needs_prefill: now gated on hi_res_enabled to make the condition explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:12:03 +02:00
Maxim Isaev f08619fb3d feat(subsonic): per-server music folder filter and sidebar picker
Apply musicFolderId across Subsonic requests, bump a filter version so library views reload, and add a fixed-position sidebar dropdown (with capped height when there are many folders).
2026-04-07 13:59:59 +03:00
Psychotoxical 5ec8fa8ba3 fix(fullscreen): remove letter-spacing from track title to fix optical misalignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:46:27 +02:00
Psychotoxical 51c118806e feat(fullscreen): track name on top, large & bold; artist name below, small
Swap DOM order and styles in the info cluster:
- fs-track-title: clamp(28px,4.5vw,68px) / weight 900 / var(--accent) — primary
- fs-artist-name: clamp(13px,1.5vw,22px) / weight 400 / 55% opacity — secondary
- fs-cluster gap: 12px → 8px (denser hierarchy)
- fs-meta: margin-top: 4px for breathing room after artist name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:44:32 +02:00
Psychotoxical fc653941c2 fix(lyrics): allow line wrapping in fullscreen lyrics overlay
Increase slot height from 3.6vh → 6vh (CSS + JS factor 0.036 → 0.06)
so that long lyric lines can wrap onto a second line without being clipped.

Fixed height on .fs-lyric-line is kept intentional — rail math requires
uniform slots. 2 wrapped lines at 2vh font / 1.4 line-height = 5.6vh,
comfortably inside the 6vh slot. font-size adjusted from 2.2vh → 2vh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:36:06 +02:00
Psychotoxical 8add62a502 perf(lyrics): fix CPU spikes during lyric transitions; fix release workflow
CSS: remove `font-weight` from transition list on `.fs-lyric-line` — it
triggers layout reflow on every animation frame when the active line changes.
Replaced with `transform: scaleX(1.015)` on `.fsl-active` (compositor-only).
Added `contain: layout style` to `.fs-lyrics-overlay` to isolate reflows.

Cargo.lock: update for thread-priority crate (added in previous audio commit).

CI: replace delete-asset workaround with `updaterJsonKeepUniversal: false`
to prevent tauri-action from uploading a latest.json with wrong signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:30:32 +02:00
Psychotoxical 44287a7ceb feat(audio): bit-perfect hi-res playback + underrun hardening (opt-in alpha)
Safe mode (default): all audio outputs at 44.1 kHz, rodio resamples
internally. Maximum stability out of the box.

Hi-Res mode (alpha toggle): stream opens at the file's native sample rate
(e.g. 88.2/96/192 kHz) for bit-perfect output.

Rust (audio.rs):
- open_stream_for_rate(): CPAL queries device supported configs, finds
  exact rate match or falls back to highest available / device default.
- create_engine() thread: ThreadPriority::Max (silently ignored without
  CAP_SYS_NICE), loop handles rate-switch requests, PIPEWIRE_LATENCY
  scales with rate (8192 frames for >48 kHz ≈ 93 ms quantum), keeps
  PULSE_LATENCY_MSEC in sync.
- audio_play(): hi_res_enabled param gates effective_rate (44100 vs
  native); stream re-opens only when needed; 150 ms PipeWire settle +
  500 ms sink pre-fill (pause→append→sleep→play) for hi-res tracks.
- audio_chain_preload(): hi_res_enabled param; rate-mismatch bail skipped
  in safe mode so gapless chains always work at 44.1 kHz.
- SizedDecoder MSS buffer: 4 MB (was 512 KB) to reduce Symphonia read
  overhead on high-bitrate hi-res files.
- thread-priority crate added to Cargo.toml.

Frontend:
- authStore: enableHiRes (bool, default false) + setEnableHiRes.
- playerStore: hiResEnabled passed to all audio_play /
  audio_chain_preload invoke calls.
- Settings → Audio tab: "Native Hi-Res Playback" toggle with Alpha
  badge and stability warning, i18n for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:20:07 +02:00
cucadmuh 0f3033d84e feat: add hot playback cache (queue prefetch, alpha)
Ephemeral on-disk cache for upcoming queue tracks. Adds Rust commands (download/delete/purge), hotCacheStore with LRU eviction, serial prefetch worker, playback gate, and Settings UI. Disabled by default.

- fix: boundary check before file delete in delete_hot_cache_track
- fix: remove stale languageRu2 key from ru.ts
- fix: restore accidentally removed lyricsServerFirst/fsLyricsToggle/lyricsSource* strings in ru.ts
2026-04-07 10:52:26 +02:00
Psychotoxical d49af977ed docs: add v1.34.1 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:12:48 +02:00
Psychotoxical d73f348339 feat: v1.34.1 — fullscreen lyrics overlay, FsArt crossfade, lyrics toggle, contributor updates
- FullscreenPlayer: synced lyrics overlay (5-line rail, mask fade, click-to-seek)
- FullscreenPlayer: FsArt crossfade component with onLoad pattern, 300px art, next-track pre-fetch
- FullscreenPlayer: MicVocal lyrics toggle button next to heart (persisted in authStore)
- FullscreenPlayer: idle system — only close button auto-hides, cluster+seekbar always visible
- LyricsPane: refactored to use shared useLyrics hook (no double-fetch with FS overlay)
- authStore: showFullscreenLyrics field (default true)
- i18n: fsLyricsToggle key added to all 7 locales; ru2.ts removed (merged into ru.ts)
- Settings: Contributors updated (nisrael, cucadmuh, kilyabin); logout moved to Server tab as btn-danger
- theme.css: btn-danger style added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:00:33 +02:00
Psychotoxical 390e6e788d chore: bump version to 1.34.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:59:32 +02:00
Psychotoxical ff950efb0c feat(settings): add nisrael to contributors, update cucadmuh/kilyabin, move logout button
- Add nisrael (Nightfox themes PR #114, rustls-tls fix PR #112)
- Update cucadmuh with gapless manual skip fix (PR #119)
- Update kilyabin with RU locale improvements (PR #120) and auto-install script (PR #121)
- Move logout button from System tab to Server tab — styled as btn-danger (outlined red)
- Add .btn-danger CSS class to theme.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:54:27 +02:00
Psychotoxical 4ef21d6d78 i18n(ru): apply translation improvements from PR #120
Manually merged kilyabin's Russian locale improvements (more natural phrasing
throughout) while preserving keys added after #120 was opened: fsLyricsToggle,
lyricsServerFirst/Desc, lyricsSourceServer/Lrclib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:46:41 +02:00
kilyabin 7fc0550b65 feat: auto-install script for Debian/RHEL 2026-04-06 22:39:19 +02:00
cucadmuh 5d06738ce1 fix(audio): gapless manual skip — honor user-initiated play over pre-chained track
* fix(audio): honor manual skip when next track is gapless-pre-chained

* fix(audio): match gapless chain and preload by stream id, not full URL
2026-04-06 22:39:16 +02:00
Psychotoxical 1197c1f916 feat: album cover art in Discord Rich Presence via iTunes Search API
Fetches album artwork from the iTunes Search API and passes the URL
directly to Discord's large_image field. Subsonic cover URLs require
auth and can't be used by Discord directly.

- 3-strategy search: exact quoted → relaxed → track title fallback
- 1-hour in-memory cache per artist|album key
- iTunes search runs in tokio::task::spawn_blocking (reqwest::blocking
  must not run on the Tokio async executor)
- artwork_cache wrapped in Arc<Mutex<...>> for cross-thread sharing
- Fallback to the pre-uploaded "psysonic" asset when no match found
- Cargo.toml: blocking feature alongside rustls-tls

Co-Authored-By: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:30:22 +02:00
Psychotoxical b448c2bc82 fix(queue): revert auto-scroll target back to queueIndex+1
The queue-current-track section already shows the active track above the
list. Scrolling to queueIndex+1 (next track) is the correct behavior —
it keeps the upcoming tracks in view, not the already-visible current one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:23:55 +02:00
cucadmuh 6226383762 i18n(ru): refine Russian locale using phrasing from ru2
Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-06 19:13:47 +02:00
nisarg 13a8f696d0 fix: queue auto-scroll to current track and fix re-renders
* stop queue panel from constant rerendering

* add queueHeader type

* fix(queue): move hook before early return, fix auto-scroll target

- usePlayerStore must be called before conditional return (Rules of Hooks)
- Auto-scroll to songs[queueIndex] not [queueIndex+1] — current track,
  not the next one

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:11:03 +02:00
Nils Israel 64e0948904 feat: integrate nightfox.nvim themes into Open Source Classics
* feat: integrate nightfox.nvim themes into Open Source Classics

Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/410b9047-62de-4c0b-a6bc-1dc6f8247164

Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>

* i18n: add Nightfox to theme FAQ in de + zh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:09:39 +02:00
Nils Israel 10b2bde5ef fix: switch reqwest to rustls-tls for cross-platform TLS compatibility
The native-tls backend (macOS Security framework) was failing with
'bad protocol version' when connecting to HTTPS music servers.
Switching to rustls (statically linked, no system dependency) fixes
playback on macOS and ensures consistent TLS 1.2/1.3 support across
all platforms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 19:06:55 +02:00
Psychotoxical 1c92f9ed74 docs: add Discord community link to README 2026-04-06 13:23:57 +02:00
Psychotoxical 443e0c0675 fix(ci): remove redundant tauri-action release params to prevent duplicate drafts 2026-04-06 13:18:03 +02:00
Psychotoxical 9cc8cfe087 fix(ci): force bash shell on Windows for gh release step 2026-04-06 13:00:43 +02:00
Psychotoxical 7ac417fbfc chore: bump AUR PKGBUILD to 1.34.0 2026-04-06 12:51:08 +02:00
Psychotoxical 50ac1b8284 feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:46:13 +02:00
Psychotoxical 29203803de feat(i18n): Russian locale and translations in src/locales
feat(i18n): Russian locale and translations in src/locales
2026-04-06 10:42:45 +02:00
Maxim Isaev 83c36de091 feat(i18n): Russian locale and translations in src/locales
- Add Russian UI strings with idiomatic phrasing and plural forms
- Split en/de/fr/nl/zh/nb strings into src/locales/*.ts for maintainability
- Register ru in i18n and Settings language picker
- Add languageRu to all locales; extend English help (supported languages)

Made-with: Cursor
2026-04-06 11:08:40 +03:00
Psychotoxical 1d0965708b Update README.md 2026-04-06 02:07:23 +02:00
Psychotoxical 2b5416f423 Update README.md 2026-04-06 02:07:04 +02:00
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
Psychotoxical 9be0d8dfa9 feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:34:26 +02:00
Psychotoxical 67f31b0700 fix: include InternetRadio.tsx (referenced in App.tsx router) 2026-04-04 02:09:19 +02:00
Psychotoxical c873880a26 feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:04:14 +02:00
Psychotoxical 463b7483fd fix: wrap next/previous in arrow functions to satisfy MouseEventHandler type 2026-04-03 15:00:36 +02:00
Psychotoxical 3d11ef91a1 chore: bump AUR pkgver to 1.30.0 2026-04-03 14:54:59 +02:00
Psychotoxical c365140870 feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix
- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:41 +02:00
Psychotoxical 651b3cb050 Update README.md 2026-04-03 12:02:34 +02:00
Psychotoxical e2ee9247ad chore: attribute contributions from JulianNymark and nisarg-78
JulianNymark contributed OGG/Vorbis support, audio error toasts, and
human-readable error messages (PRs #42, #43, #44). nisarg-78 contributed
QoL and UI improvements (PR #38). Changes were incorporated manually
due to conflicts at the time of merge.

Co-Authored-By: JulianNymark <819074+JulianNymark@users.noreply.github.com>
Co-Authored-By: nisarg-78 <84626554+nisarg-78@users.noreply.github.com>
2026-04-02 22:45:58 +02:00
Psychotoxical 74df7b6b88 Update CHANGELOG.md 2026-04-02 22:36:36 +02:00
Psychotoxical 27a6693c8c Update CHANGELOG.md 2026-04-02 22:28:35 +02:00
Psychotoxical a932e7c2db chore: bump to v1.29.0 — release prep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:24:41 +02:00
Psychotoxical 7263d93d42 feat: v1.29.0 — Radio fast-start, seek fix, OGG, error toasts, contributors
Incorporates PRs #38 (nisarg-78), #42 #43 #44 (JulianNymark) with fixes:

Audio / Playback
- Artist Radio starts immediately from fast getTopSongs (local library);
  getSimilarSongs2 (Last.fm) enriches queue in background — no more wait
- Fix seek audio glitch: EqualPowerFadeIn only resets to zero-gain on
  seeks to track start (<100 ms); all other seeks resume at unity gain
- OGG/Vorbis container support via symphonia-format-ogg (PR #42)
- Human-readable audio error messages in SizedDecoder (PR #44)
- Audio playback errors shown as themed toast notifications (PR #43)

Queue / Radio
- Infinite Queue: proactive load of 5 tracks (was 25) when ≤2 remain
- Radio: proactive reload at ≤2 remaining tracks, independent of
  Infinite Queue setting — radio no longer stops at last track
- Fix: clicking Start Radio multiple times no longer stacks duplicates
- Fix: Start Radio on artist keeps current song playing
- Manual tracks always appear before Radio, Radio before Auto-added
- Queue separators: "— Radio —" and "— Auto —" dividers
- Fix: radio proactive load now works even when songs lack artistId
  (uses currentRadioArtistId module var as fallback)

UI / UX
- Click synced lyrics lines to seek (PR #38)
- Volume scroll wheel on volume slider (PR #38)
- Lyrics: active / completed / upcoming visual states (PR #38)
- Shared toast utility (src/utils/toast.ts) replaces inline toast fn
- Auto-updater: relaunch_after_update Rust command exits first to
  release single-instance lock before spawning new process

About / Credits
- nisarg-78 and JulianNymark added to contributors (since v1.29.0)
- netherguy4 added as Special Thanks for feature ideas and feedback
- i18n: aboutSpecialThanksLabel in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:17:55 +02:00
Psychotoxical 95283d792b feat: v1.28.0 — Infinite Queue, Start Radio, Single-click Play, Performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:40:39 +02:00
Psychotoxical 53d5888ebf chore: bump AUR pkgrel to 1.27.4-2 (CFLAGS fix rollout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:25:31 +02:00
Psychotoxical cad4338324 fix(aur): unset CFLAGS/CXXFLAGS to fix ring LTO link failure on CachyOS
CachyOS makepkg.conf sets -flto=auto in CFLAGS. ring's build.rs uses the
cc crate to compile its C/asm objects, which picks up CFLAGS, producing
fat-LTO objects. bfd cannot resolve ring_core_* symbols from fat-LTO
objects when linking against non-LTO Rust rlibs.

Also adds nasm to makedepends (required by ring 0.17.x for x86_64 asm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:41:34 +02:00
Psychotoxical f9bc67cb77 fix(aur): append -fuse-ld=bfd to RUSTFLAGS — substitution was no-op on CachyOS 2026-04-02 11:54:50 +02:00
Psychotoxical 74c75d83ca fix(aur): override RUSTFLAGS to swap lld→bfd for ring linker error
makepkg.conf on Arch injects -fuse-ld=lld into RUSTFLAGS which overrides
.cargo/config.toml target rustflags. PKGBUILD now explicitly patches
RUSTFLAGS at env level so bfd is used regardless of system config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:44:00 +02:00
Psychotoxical 005abae97d fix: explicit Windows NSIS updater bundle signing step
tauri-action uploads the .exe but doesn't create .nsis.zip/.sig because
the bundler doesn't pick up TAURI_SIGNING_PRIVATE_KEY automatically.
Added a PowerShell step that zips the exe, signs it via tauri signer,
and uploads .nsis.zip + .nsis.zip.sig — mirroring the macOS approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:23:05 +02:00
Psychotoxical a9c20dfbdf chore: bump to v1.27.3 — CI fixes, ring linker fix, draft releases
- fix: CI Windows NSIS upload — let tauri-action handle artifact upload directly
- fix: Linux/AUR ring linker error — cc + -fuse-ld=bfd via .cargo/config.toml
- fix: releases now created as draft for review before publishing
- chore: consolidate 1.27.0–1.27.2 changelog into single entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:06:18 +02:00
Psychotoxical 0b5db172bd chore: bump to v1.27.2 — bugfix release
- fix: Radio from context menu passed artist name as ID to getSimilarSongs2 (closes #29)
- fix: CI Windows NSIS upload — tauri-action no longer searches missing bundle/msi/
- fix: Linux/AUR ring linker error — use cc instead of rust-lld via .cargo/config.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:38:26 +02:00
Psychotoxical bf99a64baf fix: guard against empty NSIS bundle in Windows signing step
tauri-action already signs and uploads the .nsis.zip.sig when
TAURI_SIGNING_PRIVATE_KEY is set, so the manual find returns empty
and crashed with "a value is required for <FILE>". Exit 0 cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 03:23:17 +02:00
Psychotoxical 523596e414 chore: bump AUR pkgver to 1.27.1 2026-04-02 03:08:52 +02:00
Psychotoxical 2fe35e3f9b fix: v1.27.1 — CI signing pipeline, sig naming, npm+cargo caching 2026-04-02 03:07:19 +02:00
Psychotoxical e9fa541933 chore: add npm + cargo caching to all CI jobs (netherguy4 #28) 2026-04-02 02:54:58 +02:00
Psychotoxical 746aa69405 fix: explicit bundle signing step after tauri-action 2026-04-02 02:53:08 +02:00
Psychotoxical 205b2c1914 fix: signing env at job level, fix macOS bundle filenames in manifest 2026-04-02 02:38:53 +02:00
Psychotoxical 0086b3e310 chore: bump AUR pkgver to 1.27.0 2026-04-02 02:15:26 +02:00
Psychotoxical 55e7cb835b feat: v1.27.0 — In-App Auto-Update, Configurable Home, Icon Consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 02:14:57 +02:00
Psychotoxical d8da511a8f fix: v1.26.1 — hero/album/playlist background flickering
Background of Hero carousel, Album Detail and Playlist Detail was
flickering for up to 20 seconds on first visit. useCachedUrl with
fallbackToFetch=true returned the raw server URL immediately, causing
a double render — once with the HTTP URL and again when the blob was
ready. Fixed by passing fallbackToFetch=false in all three locations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:20:15 +02:00
Psychotoxical 434ee0ecf1 feat: v1.26.0 — Bulk Select, Song Info, Favorite Button, Recently Played
### Added
- Favorite/Star button in player bar (requested by @halfkey)
- Bulk multi-select for album tracklist and playlist detail (add to playlist, remove from playlist)
- Song Info modal via right-click context menu (metadata: format, bitrate, sample rate, bit depth, channels, file size, path, replay gain)
- Recently Played section on Home page
- "Show activity in Now Playing" opt-in toggle in Settings → Behavior

### Fixed
- Queue cover art not updating on track change (useCachedUrl/CachedImage reset on cacheKey change)
- FullscreenPlayer background flickering (FsBg preloads image before layer transition)
- Playlist card delete confirmation visual (size expansion + pulse animation)
- Gruvbox Light Soft: back button and badge invisible against light background

### Changed
- buildStreamUrl: removed unused suffix parameter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:57:57 +02:00
Psychotoxical 7d1c66071e Update CHANGELOG.md 2026-04-01 17:28:50 +02:00
Psychotoxical 1adfda1daa fix: remove invalid single-instance capability + dead static tray icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:51:50 +02:00
Psychotoxical e65c476a76 fix: v1.25.1 — Opus playback, single-instance enforcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:45:37 +02:00
Psychotoxical 560349819f feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:41 +02:00
Psychotoxical ada5327493 chore: bump AUR pkgver to 1.24.0 2026-03-31 23:14:45 +02:00
Psychotoxical c67d606f89 feat: v1.24.0 — Playlist Management, native sample rate playback
- Full playlist feature: overview grid, detail page with hero collage,
  tracklist DnD, song search, suggestions, context menu submenu
- Audio: disable all app-level resampling — every track plays at its
  native sample rate (target_rate always 0 in audio_play + chain_next)
- Fix: playlist hero bg flicker (memoize buildCoverArtUrl calls)
- Fix: input focus double-border (search-input → .input class)
- Polish: redesigned playlist search panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:14:15 +02:00
Psychotoxical 662cc94ca8 fix: disable forced resampling to 44100 Hz on first track
current_sample_rate was initialized to 44100, causing every track to be
resampled down to 44.1 kHz. Setting it to 0 disables resampling until
the actual track rate is known, so songs play at their native sample rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:01:58 +02:00
Psychotoxical 1eacaf678c Update README.md 2026-03-30 23:16:47 +02:00
Psychotoxical 4a8fb64c66 Update README.md 2026-03-30 23:16:04 +02:00
Psychotoxical 43c656dfc3 feat: v1.23.0 — Advanced Search, Genre Mix overhaul, Playlist append, Contributors table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:56:59 +02:00
Psychotoxical 74b519f9f5 feat: Replay Gain support — songToTrack() + audio_update_replay_gain
Integrates PR #9 by @trbn1: replay gain was not applying because track
objects were built manually without replayGainTrackDb/replayGainAlbumDb/
replayGainPeak fields. All track construction sites now use songToTrack().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:46 +02:00
Psychotoxical 3d03b8d5a1 merge: PR #9 — Replay Gain fix (trbn1) + conflict resolution
Merges songToTrack() helper and audio_update_replay_gain Tauri command
from @trbn1 without losing v1.22.0 DnD/queue-management work:

- All track construction sites use songToTrack() for correct replay gain
- psy-drag system (mouse-event DnD) preserved across all components
- enqueueAt() position-aware insertion preserved in QueuePanel
- updatePlaylist / smart-save / active-playlist tracking preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:29 +02:00
Psychotoxical d6f6e6466c feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:14:59 +02:00
trbn 95cdbc7fc7 fix: replay gain not applying to tracks
Replay gain was not working because track objects were created manually without
including replay gain metadata from the Subsonic API response.

Changes:
- Add songToTrack() helper function to properly map SubsonicSong to Track with
  replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields
- Add audio_update_replay_gain Tauri command for dynamic volume recalculation
  when replay gain settings change mid-playback
- Add updateReplayGainForCurrentTrack() to recalculate volume when toggling
  replay gain setting
- Fetch fresh track data on cold resume (app relaunch) to ensure replay gain
  values are current from server
- Update all files that create track objects to use songToTrack()

Fixes issue where toggling replay gain ON/OFF or changing between track/album
mode had no effect on currently playing or newly played tracks.
2026-03-30 19:11:33 +02:00
Psychotoxical 42863877f6 fix: close seek-flash race window after debounce expires
PR #7 blocked stale Rust progress ticks during the 100 ms debounce
window but lifted the guard exactly when invoke('audio_seek') went out.
The engine can still emit 1–2 ticks with the old position before the
seek takes effect.  Add seekTarget: once the debounce fires, record the
requested time and keep ignoring progress until current_time is within
2 s of that target, then clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:48 +02:00
Psychotoxical 7ed0fa4914 Merge branch 'pr-8' into test/pr-7-8 2026-03-30 17:24:18 +02:00
Joshua Bassett 4f8e7d7bc7 fix: stabilize waveform seekbar width when player time changes
Add min-width to .player-time elements so they don't resize when the
displayed time string changes length (e.g. "9:59" → "10:00"). Not all
fonts support the tabular-nums font-variant, so without a min-width the
time elements can change size and cause the waveform seekbar to
continuously resize as playback progresses.

Also align the elapsed time to the right and duration to the left so
they sit flush against the waveform edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:17:50 +11:00
Joshua Bassett bb56269cd1 fix: prevent waveform seekbar flashing back to old position on seek
When clicking/dragging the waveform seekbar, the playback position would
briefly flash back to the previous position before snapping to the new
one.

The seek function optimistically updates the store with the target
position immediately but debounces the actual audio_seek IPC call by
100ms. During that window, audio:progress events from the Rust backend
continue to arrive carrying the old playback position, overwriting the
optimistic update and causing the visual flash.

The fix skips incoming audio:progress events while a seek debounce is
pending, since the store already holds the correct target position. Once
the debounce fires and audio_seek is sent to the backend, progress
events resume normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:55 +11:00
Psychotoxical 29a4363dca feat: v1.21.0 — What's New Modal, 3 New Themes (Beta), Artist Column, Powerslave Blue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:48:40 +02:00
Psychotoxical e1d27798eb feat: v1.20.0 — FLAC Seek Fix, Genres, Genre Filter, Chinese, W10 Theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:04:01 +02:00
Psychotoxical b35539d3cf Merge pull request #3 from jiezhuo/feature/chinese_translations
Add Chinese language
2026-03-29 16:32:46 +02:00
jiezhuo a1b3022140 Add Chinese language 2026-03-28 22:04:13 +08:00
Psychotoxical b6fb66c46d feat: v1.19.0 — NSIS Installer, Tray Removed, Storage Warning, Theme Polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:56:35 +01:00
Psychotoxical 936e548f40 feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:17:09 +01:00
Psychotoxical b67c198227 fix: player bar CSS + Windows media keys HWND (v1.17.2)
- layout: overflow:hidden on .app-shell + min-height:0 on sidebar/main/queue
  prevents player bar from being pushed off-screen when window is resized
  below OS minHeight constraint (ignored by some Linux WMs)
- lib.rs: pass main window HWND to souvlaki PlatformConfig on Windows so
  SMTC hooks into the existing Win32 message loop instead of creating its
  own — fixes media keys on Windows without crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:15:25 +01:00
Psychotoxical 65a828e3fa fix: disable souvlaki on Windows to fix startup crash (v1.17.1)
SMTC init via souvlaki requires a valid HWND + COM message loop, which
are not available in setup(). Guarded behind #[cfg(not(target_os = "windows"))].
mpris_set_metadata / mpris_set_playback no-op on Windows via the None branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:02:09 +01:00
Psychotoxical 6bdd6f3a59 feat: v1.17.0 — Media Keys, 3 New Themes, Perf Fixes, Contrast Audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:30:13 +01:00
Psychotoxical d62bffd082 feat: v1.16.0 — 15 New Themes, W98 Overhaul, Aqua Quartz Polish
New themes: Aqua Quartz (Mac OS X Aqua, skeuomorphic), Spider-Tech,
T-800, B-Runner, Hill Valley 85, TetraStack, Turtle Power, Insta,
ReadIt, The Book (new Social Media group), W3.1, Jayfin (Jellyfin).

W98 rebuilt from scratch: authentic #d4d0c8 warm-gray, full 4-layer
3D bevel on all panels/buttons (raised/sunken on press), title bar
gradient on song name, navy progress fill, 16px styled scrollbar.

Aqua Quartz: all button variants now jelly-styled, blue Source List
sidebar with white pill nav, aluminium pinstripe background.

Theme Picker: groups and themes sorted alphabetically, Mediaplayer
group renamed, Pandora/Order of the Phoenix/Imperial Sith removed.

Fix: AlbumDetail genre propagation, W98 accordion active state,
Aqua Quartz sidebar labels, W98 connection indicator contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:54:57 +01:00
Psychotoxical ff706104ab fix: propagate genre in AlbumDetail track constructions
Play All, Enqueue All, and single-song play in AlbumDetail were
building Track objects without genre/starred fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:57:17 +01:00
Psychotoxical 0abef4b266 docs: update README screenshot 2026-03-23 19:52:18 +01:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
Psychotoxical 361e9cfdb3 feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:00:55 +01:00
Psychotoxical 5516d95b52 feat: v1.13.0 — SVG Logo, Marquee, Player UX, Global Shortcuts fix
### Added
- SVG logo with theme-adaptive gradient in sidebar (full wordmark + P-icon for collapsed state)
- Player bar: song title/artist marquee scroll on overflow
- Player bar: live volume percentage tooltip on slider hover

### Changed
- Sidebar collapse button moved to right-edge hover tab
- Player bar: fixed 320px track info width, increased waveform margins
- Settings: Server tab opens by default
- Crossfade: experimental badge removed (stable)
- Help page: Lyrics/Keybindings/Font entries added, theme count corrected

### Fixed
- Global shortcuts double-fire (Rust-side ShortcutMap idempotency fix)
- W98 theme: comprehensive contrast fixes for navy hover backgrounds
- Help page: removed orphaned translation key under Playback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:24:14 +01:00
Psychotoxical d927ef2082 feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:06 +01:00
Psychotoxical 867c5fbd3e feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:37:49 +01:00
Psychotoxical e550340565 feat: v1.10.0 — new streaming themes, favourite toggle fix, home page improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:58:05 +01:00
Psychotoxical 57b70e6154 feat: v1.9.0 — new themes, keybindings, font picker, home play behavior
Themes:
- Add Neon Drift (midnight blue / electric cyan synthwave)
- Add Cupertino Light + Cupertino Dark (macOS Ventura-inspired, frosted glass)
- Add Betriebssysteme group (Cupertino, Aero Glass, Luna Teal)
- Rename all trademarked theme IDs to original names (WnAmp, Navy Jukebox,
  Cobalt Media, Onyx Cinema, Aero Glass, Luna Teal)
- Reorder ThemePicker: Psysonic Themes first, then Mediaplayer, Betriebssysteme

Keybindings:
- New keybindingsStore with 10 bindable actions (play/pause, next, prev,
  volume, seek ±10s, queue, fullscreen, native fullscreen)
- Settings UI for rebinding; defaults: Space=play-pause, F11=native-fullscreen

Font picker:
- New fontStore; 10 UI fonts selectable in Settings → Appearance
- Applied via data-font attribute on <html>

Home page:
- AlbumCard: Details button → Play button via playAlbum() utility
- Hero: Play Album starts playback with 700ms fade-out instead of navigating
- playAlbum.ts: fade, store-only volume restore, playTrack handoff

Now Playing:
- 3-column hero layout; EQ bars truly centred (flex:1 on both outer columns)
- Background brightness 0.25→0.55, overlay 0.55→0.38 (art now visible)
- Better text contrast for track times, card links, section titles

Linux audio:
- Set PIPEWIRE_LATENCY + PULSE_LATENCY_MSEC before stream creation
  to reduce ALSA snd_pcm_recover underrun frequency on PipeWire

Remove butterchurn visualizer (VisualizerCanvas, butterchurn.d.ts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:22:02 +01:00
227 changed files with 75408 additions and 20670 deletions
+12
View File
@@ -0,0 +1,12 @@
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
# bfd is always available via binutils (part of base-devel on Arch).
#
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
# overrides target-specific rustflags here. The PKGBUILD's build() applies
# the same fix by patching the RUSTFLAGS env var directly.
[target.x86_64-unknown-linux-gnu]
linker = "cc"
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: psychotoxic
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+18 -5
View File
@@ -19,6 +19,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
@@ -65,7 +66,7 @@ jobs:
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body,
draft: false,
draft: true,
prerelease: false
});
return data.id;
@@ -83,7 +84,7 @@ jobs:
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: ''
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
@@ -91,10 +92,15 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
@@ -119,16 +125,22 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
@@ -136,7 +148,8 @@ jobs:
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
@@ -144,5 +157,5 @@ jobs:
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
+10
View File
@@ -31,6 +31,16 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
# Documentation
CLAUDE.md
# Claude Code memory (local only)
memory/
# Local scratchpad / notes (not committed)
tmp/
# Third-party clones for local research (not committed)
research/
+1253
View File
File diff suppressed because it is too large Load Diff
-213
View File
@@ -1,213 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Psysonic
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
## Commands
```bash
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
npm run tauri:dev
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
# Production build
npm run tauri:build
# Frontend-only dev server (no Tauri shell)
npm run dev
# Type-check + bundle frontend
npm run build
```
There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
## Architecture
### Stack
- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView
- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine
- **State**: Zustand stores (no Redux)
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
- **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time).
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch)
### Key files
| File | Role |
|---|---|
| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()`, `coverArtCacheKey()`, `reportNowPlaying()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
| `src/api/lastfm.ts` | Last.fm API: scrobble, updateNowPlaying, love/unlove, getTrackLoved, getSimilarArtists, getTopArtists/Albums/Tracks, getRecentTracks, getUserInfo. Auth via session key stored in `authStore`. |
| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. |
| `src/components/CachedImage.tsx` | Drop-in `<img>` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
| `src/components/TooltipPortal.tsx` | Global tooltip system. Listens for `mouseover`/`mouseout` on `document`, reads `data-tooltip` / `data-tooltip-pos` / `data-tooltip-wrap` attributes, renders via `createPortal` to `document.body` at `z-index: 99999`. Mounted once in `App.tsx`. Use `data-tooltip` instead of native `title=` everywhere — `title=` produces unstyled OS tooltips. |
| `src/components/CustomSelect.tsx` | Styled portal-based dropdown replacing native `<select>`. Accepts `SelectOption[]` with optional `group` and `disabled`. Positioned via `useLayoutEffect`, flips above trigger if near viewport bottom. Use for all select inputs. |
| `src/components/LastfmIcon.tsx` | Shared Last.fm SVG logo component. `<LastfmIcon size={16} />`. |
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `<html>` |
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `<TooltipPortal />` mount |
| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 110 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. |
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
### Multi-server support
`authStore` holds a `ServerProfile[]` array and an `activeServerId`. The `ServerProfile` shape is:
```typescript
interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
```
Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL.
### Login / connection flow
- Connection is tested with `pingWithCredentials(url, username, password)` from `src/api/subsonic.ts` **before** writing anything to the store.
- Only after a successful ping: `addServer()` + `setActiveServer()` + `setLoggedIn(true)`.
- `RequireAuth` in `App.tsx` redirects to `/login` if `!isLoggedIn || !activeServerId || servers.length === 0`.
- **Do not** call `addServer()` before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
### Auth salt security
`secureRandomSalt()` in `subsonic.ts` uses `crypto.getRandomValues()` (not `Math.random()`) for all token auth salts.
### Image caching
`buildCoverArtUrl()` generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
- `coverArtCacheKey(id, size)` — stable key: `${serverId}:cover:${id}:${size}`
- `CachedImage` component or `useCachedUrl` hook — resolve via IndexedDB, fall back to direct URL
- Use `useCachedUrl` (not `CachedImage`) for CSS `background-image` properties
- **Gotcha**: `useCachedUrl` / hooks from `CachedImage.tsx` must be called unconditionally, before any early `return` in the component. Derive inputs from nullable state (e.g. `album?.album.coverArt`) rather than placing the hook after guard returns.
### Data flow
1. `authStore.getBaseUrl()` returns the active server's URL
2. `subsonic.ts` calls `useAuthStore.getState()` directly (not hooks) to build each request
3. `playerStore.playTrack()` calls `invoke('audio_play', { url, volume, durationHint })`, calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm), listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% directly via Last.fm API, and debounces server queue sync
4. Tauri events (`media:play-pause`, `tray:play-pause`, etc.) are bridged to store actions in `TauriEventBridge` inside `App.tsx`
5. On cold start (app restart): if `currentTrack` is in localStorage, `resume()` calls `audio_play` + seeks to saved `currentTime`
### Adding a new page
1. Create `src/pages/MyPage.tsx`
2. Add a `<Route>` in `AppShell` in `src/App.tsx`
3. Add a sidebar link in `src/components/Sidebar.tsx`
4. Add i18n keys to both `enTranslation` and `deTranslation` in `src/i18n.ts`
### Adding a new Subsonic API call
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
21 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by Classic Winamp for orange volume, yellow accent elsewhere).
| Theme | Group | Style | Accent |
|---|---|---|---|
| `mocha` | Catppuccin | dark | Mauve |
| `macchiato` | Catppuccin | medium-dark | Mauve |
| `frappe` | Catppuccin | medium | Mauve |
| `latte` | Catppuccin | light | Mauve |
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` |
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` |
| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` |
| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` |
| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` |
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
### Artists page — initial avatars
Artist images are intentionally **not loaded** on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB). In the grid view, the initial avatar is a **circle** (`border-radius: 50%`, `border: 2px solid` with the accent colour) centred inside the card. Name and album count below are centre-aligned.
### Artist cards
`ArtistCardLocal` uses the same structure as `AlbumCard`: no padding, full-width square cover via `aspect-ratio: 1`, info below. Both use `flex: 0 0 clamp(140px, 15vw, 180px)` inside `.album-grid` so they stay the same size as album cards.
### NowPlayingDropdown — Live button
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
### i18n
All non-English strings live exclusively in `src/i18n.ts` — never hardcode translated text in `.tsx` files. Four languages: `en`, `de`, `fr`, `nl`. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
### Tauri capabilities
Tauri v2 capability configs live in `src-tauri/capabilities/`. Schema is auto-generated into `src-tauri/gen/schemas/`. Modify capabilities there when adding new Tauri plugins or IPC commands.
## Release / CI
Releases are triggered by pushing a `v*` tag. The GitHub Actions workflow (`.github/workflows/release.yml`) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
The workflow is split into three jobs: `create-release` (creates the GitHub Release with changelog body from CHANGELOG.md), `build-macos-windows` (macOS + Windows via tauri-action), and `build-linux` (Ubuntu 24.04, manual, builds only deb + rpm via `--bundles deb,rpm`).
**AppImage is no longer built.** The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
**Never force-push or move a tag after publishing.** GitHub caches release tarballs — moving a tag causes the AUR and other package managers to build stale code. Bump the patch version instead.
### Linux distribution channels
| Distro family | Package |
|---|---|
| Ubuntu / Debian | `.deb` from GitHub Releases |
| Fedora / RHEL | `.rpm` from GitHub Releases |
| Arch / CachyOS | AUR: `yay -S psysonic` or `paru -S psysonic` |
### AUR package (`packages/aur/PKGBUILD`)
- Maintained at `aur.archlinux.org/packages/psysonic` (account: Psychotoxical)
- Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
- Installs a wrapper script at `/usr/bin/psysonic` setting `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`
- **When releasing**: bump `pkgver` in `packages/aur/PKGBUILD`, copy to `~/aur-psysonic/`, run `makepkg --printsrcinfo > .SRCINFO`, commit and push to AUR remote
## Notes
- Media key shortcuts (`MediaPlayPause`, etc.) are **not registered on Linux** (see `#[cfg(not(target_os = "linux"))]` in `lib.rs`). Spacebar is the keyboard shortcut for play/pause instead.
- `playerStore` persists `volume`, `repeatMode`, `currentTrack`, `queue`, `queueIndex`, and `currentTime` to `localStorage` via Zustand `partialize`. The audio engine state is runtime-only (Rust side).
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs`**not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
- **Fullscreen Player ("Ambient Stage")**: Single centered column — no tracklist. Background uses the artist's `largeImageUrl` from `getArtistInfo()` (falls back to cover art). Ken Burns animation: `inset: -30%`, ±8% translate, 90s cycle. No color orbs (removed — too GPU-intensive). Cover has a slow breathing animation (`cover-breathe` keyframe). Long song titles scroll as a marquee (`MarqueeTitle` component — measures overflow via `getBoundingClientRect` + `ResizeObserver`, animates via CSS custom property `--scroll-amount`). `Track.artistId` is populated from `SubsonicSong.artistId` (Navidrome returns this field) across all 18 track-construction sites.
- **Sidebar**: Fixed width via CSS `clamp(200px, 15vw, 220px)` — no drag-to-resize. Collapsed state (72px) persisted in `localStorage`. Update notification uses Tauri Shell plugin `open()` to launch the system browser — `<a target="_blank">` does not work inside a Tauri WebView.
- **Artist page — external links**: Last.fm and Wikipedia buttons open in the system browser via `open()` from `@tauri-apps/plugin-shell`. Button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 s as visual confirmation.
- **Tracklist columns**: Order is `# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format`. Format column uses `120px` (NOT `auto` or `1fr`) — `auto` caused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so the `fr` title column calculated differently in header vs rows, shifting all subsequent columns. `1fr` fixed alignment but made the format column too wide. Fixed `120px` fits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicit `grid-column` numbers (not negative indices).
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
- **Random Mix — Genre Filter**: `excludeAudiobooks` + `customGenreBlacklist` in `authStore` (persisted). Hardcoded `AUDIOBOOK_GENRES` list in `RandomMix.tsx` and `AUDIOBOOK_GENRES_DISPLAY` in `Settings.tsx` must be kept in sync. Filter checks `song.genre`, `song.title`, and `song.album`. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly.
- **Random Mix — Super Genre Mix**: 9 super-genres defined in `SUPER_GENRES` constant. Server genres fetched via `getGenres()` on mount; `availableSuperGenres` filters to those with ≥1 keyword match. `loadGenreMix` uses progressive rendering — `setGenreMixSongs` updated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching. `genreMixComplete` state gates the "Play All" button: button stays `btn-surface` with live `n / 50` counter while loading, switches to `btn-primary` only when all songs are ready.
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
- **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal.
- **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget.
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Version**: 1.8.0
+47
View File
@@ -0,0 +1,47 @@
# Privacy Policy
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
## Data sent to third-party services
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Last.fm
If you connect a Last.fm account in Settings, Psysonic sends:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album)
- **Love / Unlove** — when you mark a track as loved or unloved
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
### NetEase Cloud Music (Lyrics)
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
### Apple Music / iTunes Search API
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
### Discord Rich Presence
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
## Data stored locally
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
- Last.fm session key
- Playback preferences, themes, keybindings, and all other settings
- Synced device manifests
## No telemetry
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
## Open source
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
+81 -53
View File
@@ -1,6 +1,6 @@
<div align="center">
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
<p>
@@ -8,9 +8,23 @@
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
> [!WARNING]
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
---
<div align="center">
<a href="https://discord.gg/ckVPGPMS">
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
</a>
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
</div>
---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
@@ -18,73 +32,79 @@ 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.
![Psysonic Screenshot](public/screenshot.png)
![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features
- 🎨 **Gorgeous UI**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
- 🎛 **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
-**Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (15 stars), and multi-select actions.
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
- 🖥 **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
## 🗺️ Roadmap
### ✅ Completed
- [x] Native Rust/rodio audio engine (replaces Howler.js)
- [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks
- [x] Replay Gain (track + album mode)
- [x] Gapless playback *(experimental)*
- [x] MilkDrop/Butterchurn visualizer
- [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
- [x] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
- [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS)
### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements
### 📋 Planned
- [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
## 📥 Installation
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` or `.msi`
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
### 🐧 Linux
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
**Quick Install (Recommended):**
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
**Manual Installation:**
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 Installation (Arch Linux / AUR)
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
| Package | Type | Description |
| :--- | :--- | :--- |
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
> [!TIP]
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
## 🚀 Getting Started
@@ -100,6 +120,10 @@ If you want to build Psysonic from source or contribute to the project:
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/) (v1.75+)
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
- macOS: `brew install cmake`
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
### Setup
@@ -134,3 +158,7 @@ Contributions are completely welcome! Whether it is translating the app into a n
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
## 🔒 Privacy
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
+36
View File
@@ -0,0 +1,36 @@
# Shell completion for `psysonic`
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
## zsh
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
```sh
mkdir -p ~/.zsh/completions
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
```
If you use a plugin manager, point its `fpath` at this repos `completions/` directory instead.
## bash
Source the script once (e.g. in `~/.bashrc`):
```sh
source /path/to/psysonic/completions/psysonic.bash
```
Use this only under **bash** (including macOSs `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
## Device names after `audio-device set`
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
## Folder ids after `library set`
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
+144
View File
@@ -0,0 +1,144 @@
#compdef psysonic
# Zsh completion for Psysonic CLI (see `psysonic --help`).
_psysonic_audio_device_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_audio_device_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_audio_device_json)" || return
local -a devs
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
(( $#devs )) && compadd -a devs
}
_psysonic_library_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_library_folder_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_library_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_snapshot_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_server_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_snapshot_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --player completions
}
integer i pidx=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if (( pidx == 0 )); then
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
_psysonic_globals
return
fi
local -a sub
if (( pidx + 1 <= CURRENT - 1 )); then
sub=( ${words[pidx + 1,CURRENT - 1]} )
else
sub=()
fi
integer n=${#sub[@]}
if (( n == 0 )); then
compadd -J verbs -X 'player command' -- \
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
audio-device library server search mix
return
fi
case ${sub[1]} in
audio-device)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd default
_psysonic_audio_device_ids
fi
;;
library)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd all
_psysonic_library_folder_ids
fi
;;
mix)
(( n == 1 )) && compadd append new
;;
server)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
_psysonic_server_ids
fi
;;
search)
if (( n == 1 )); then
compadd track album artist
elif (( n >= 2 )); then
_message -e descriptions 'search query'
fi
;;
repeat)
(( n == 1 )) && compadd off all one
;;
rating)
(( n == 1 )) && compadd 0 1 2 3 4 5
;;
seek)
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
;;
esac
+167
View File
@@ -0,0 +1,167 @@
# bash completion for Psysonic (see `psysonic --help`).
# Install: source /path/to/completions/psysonic.bash
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
#
# Uses no `mapfile` so bash 3.2 (macOS default) works.
#
# compopt is bash-only (programmable completion). Guard so sourcing this file
# under zsh or plain sh does not print "command not found: compopt".
_psysonic_compopt() {
command -v compopt &>/dev/null || return 0
compopt "$@" 2>/dev/null || return 0
}
_psysonic_compreply_from_compgen() {
# $1 = compgen -W word list, $2 = current word
COMPREPLY=()
local line
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W "$1" -- "$2")
}
_psysonic_audio_device_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_library_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_snapshot_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
done
if (( pidx == 0 )); then
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
return
fi
local -a sub=()
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
sub+=("${COMP_WORDS[i]}")
done
local n=${#sub[@]}
if (( n == 0 )); then
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
return
fi
case ${sub[0]} in
audio-device)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf d
jf="$(_psysonic_audio_device_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r d; do
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'default' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
library)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf id line
jf="$(_psysonic_library_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r id; do
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'all' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
mix)
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
;;
server)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf sid
jf="$(_psysonic_snapshot_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r sid; do
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
search)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'track album artist' "$cur"
elif (( n >= 2 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
repeat)
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
;;
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek|volume)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
esac
}
complete -F _psysonic_complete psysonic
-3
View File
@@ -6,9 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+583 -148
View File
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.8.0",
"version": "1.34.12",
"private": true,
"scripts": {
"dev": "vite",
@@ -8,23 +8,37 @@
"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": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
@@ -35,10 +49,12 @@
"@tauri-apps/cli": "^2",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/papaparse": "^5.5.2",
"@types/react": "^18.3.11",
"@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"
}
}
+19 -3
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.8.0
pkgver=1.34.12
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -9,7 +9,6 @@ license=('GPL-3.0-only')
depends=(
'webkit2gtk-4.1'
'gtk3'
'libayatana-appindicator'
'openssl'
'alsa-lib'
)
@@ -17,6 +16,9 @@ makedepends=(
'npm'
'rust'
'cargo'
'clang'
'nasm'
'cmake'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
@@ -27,6 +29,20 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
# 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
# because the last -fuse-ld=* flag passed to cc wins.
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
unset CFLAGS CXXFLAGS
npm install
npm run tauri:build -- --no-bundle
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="115.549mm"
height="130.30972mm"
viewBox="0 0 115.549 130.30972"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:export-filename="p-small.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"><inkscape:page
x="0"
y="0"
width="115.549"
height="130.30972"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1" /><g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(220.53237,27.789086)"><path
style="fill:#ffffff"
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
id="path1" /></g></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

+52
View File
@@ -0,0 +1,52 @@
# Scripts
## install.sh - Auto-Installer for Debian and RHEL-based Systems
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
### Supported Distributions
- **Debian/Ubuntu**: Downloads and installs `.deb` package
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
### Usage
#### Quick Install (Recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
#### Manual Installation
```bash
# Download the script
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
# Make it executable
chmod +x install.sh
# Run with sudo
sudo ./install.sh
```
### What it does
1. Detects your OS type (Debian or RHEL-based)
2. Fetches the latest release from GitHub
3. Downloads the appropriate package (.deb or .rpm)
4. Installs it using your system's package manager
5. Cleans up temporary files
### Requirements
- `curl` - for downloading packages
- `sudo` or root access
- Internet connection
- Supported package manager (apt-get, dnf, or yum)
### Notes
- If Psysonic is already installed, the script will ask if you want to reinstall
- The script automatically handles dependency installation for Debian systems
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
set -e
# Psysonic Auto-Installer
# Automatically detects your OS and installs the latest release from GitHub
REPO="Psychotoxical/psysonic"
APP_NAME="psysonic"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
error "Please run this script as root (use sudo)"
fi
}
# Detect package manager and OS type
detect_os() {
if command -v apt &> /dev/null; then
OS_TYPE="debian"
PACKAGE_MANAGER="apt"
info "Detected Debian/Ubuntu-based system (apt)"
elif command -v dnf &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="dnf"
info "Detected RHEL/Fedora-based system (dnf)"
elif command -v yum &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="yum"
info "Detected RHEL/CentOS-based system (yum)"
else
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
fi
}
# Get the latest release download URL for the specific package type
get_download_url() {
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
info "Fetching latest release information..."
local release_info
release_info=$(curl -s "$api_url")
if echo "$release_info" | grep -q "message.*Not Found"; then
error "Could not fetch release information. Please check your internet connection."
fi
local tag_name
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$tag_name" ]; then
error "Could not determine latest release version."
fi
info "Latest version: $tag_name"
local download_url=""
if [ "$OS_TYPE" = "debian" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
elif [ "$OS_TYPE" = "rhel" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$download_url" ]; then
error "Could not find download URL for $OS_TYPE package."
fi
echo "$download_url"
}
# Install the package
install_package() {
local download_url="$1"
local temp_dir
temp_dir=$(mktemp -d)
local package_file="$temp_dir/${APP_NAME}_latest"
info "Downloading package..."
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
warn "Trying to fix broken dependencies..."
$PACKAGE_MANAGER install -f -y
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
fi
# Cleanup
rm -rf "$temp_dir"
}
# Check if app is already installed
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
read -p "Do you want to reinstall? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
fi
fi
}
# Main installation flow
main() {
echo -e "${GREEN}"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ Psysonic Auto-Installer ║"
echo "║ Install the latest release from GitHub Releases ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
check_root
detect_os
check_installed
local download_url
download_url=$(get_download_url)
if [ -z "$download_url" ]; then
error "Failed to get download URL."
fi
info "Download URL: $download_url"
install_package "$download_url"
echo ""
success "Psysonic has been installed successfully!"
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
echo ""
}
# Run main function
main
+1548 -510
View File
File diff suppressed because it is too large Load Diff
+42 -5
View File
@@ -1,13 +1,13 @@
[package]
name = "psysonic"
version = "1.8.0"
version = "1.34.12"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
repository = ""
default-run = "psysonic"
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.89"
[lib]
name = "psysonic_lib"
@@ -22,8 +22,8 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
@@ -31,8 +31,45 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
url = "2"
thread-priority = "1"
lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_UI_Controls",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[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" }
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
<key>com.apple.security.device.audio-input</key>
<false/>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+8 -2
View File
@@ -8,7 +8,6 @@
"core:default",
"shell:default",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
@@ -18,19 +17,26 @@
"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-start-dragging",
"core:window:allow-create",
"core:webview:allow-create-webview-window"
"core:webview:allow-create-webview-window",
"process:allow-restart"
]
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-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"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1 @@
{"v":1}
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
},
"path_in_vcs": "symphonia-format-isomp4"
}
+94
View File
@@ -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
[![Docs](https://docs.rs/symphonia-format-isomp4/badge.svg)](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
}
}
+2711 -147
View File
File diff suppressed because it is too large Load Diff
+1268
View File
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
/// Discord Rich Presence integration.
///
/// Album artwork is fetched from the iTunes Search API and passed directly to
/// Discord via the large_image URL field. This avoids the need to pre-upload
/// assets to the Discord Developer Portal.
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
const DISCORD_APP_ID: &str = "1489544859718258779";
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
pub struct ArtworkCacheEntry {
pub url: String,
pub fetched_at: Instant,
}
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
pub struct DiscordState {
pub client: Mutex<Option<DiscordIpcClient>>,
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
pub http_client: Client,
}
impl DiscordState {
pub fn new() -> Self {
DiscordState {
client: Mutex::new(None),
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
http_client: Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
}
// ─── iTunes Search API ───────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResponse {
results: Vec<ItunesResult>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResult {
collectionName: Option<String>,
artistName: Option<String>,
artworkUrl100: Option<String>,
}
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
fn normalize(s: &str) -> String {
s.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Search for album artwork via iTunes Search API.
/// Returns a higher-resolution URL (600x600) if found.
///
/// Takes explicit `client` and `cache` so this can be called from inside
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
fn search_itunes_artwork(
client: &Client,
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
artist: &str,
album: &str,
title: &str,
) -> Option<String> {
let cache_key = format!("{}|{}", artist, album);
// Check cache first
{
let c = cache.lock().ok()?;
if let Some(entry) = c.get(&cache_key) {
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
return Some(entry.url.clone());
}
}
}
let norm_artist = normalize(artist);
let norm_album = normalize(album);
let norm_title = normalize(title);
// Strategy 1: exact match search — "artist" "album"
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "5");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 2: relaxed search — artist album (no quotes)
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 3: search by track title — artist + title (for singles/rare albums)
if !title.is_empty() {
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, title))
.append_pair("media", "music")
.append_pair("entity", "song")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
}
None
}
fn search_with_url(
client: &Client,
url: url::Url,
norm_artist: &str,
norm_album: &str,
) -> Option<String> {
let resp = client.get(url).send().ok()?;
let body: ItunesResponse = resp.json().ok()?;
for result in &body.results {
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
// Flexible matching: check if strings contain each other
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
let artist_match = norm_artist == result_artist
|| norm_artist.contains(&result_artist)
|| result_artist.contains(&norm_artist)
|| words_overlap(norm_artist, &result_artist);
let album_match = norm_album == collection
|| norm_album.contains(&collection)
|| collection.contains(norm_album)
|| words_overlap(norm_album, &collection);
if artist_match && album_match {
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
}
}
None
}
/// Check if two strings share at least 50% of their words.
fn words_overlap(a: &str, b: &str) -> bool {
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
if words_a.is_empty() || words_b.is_empty() {
return false;
}
let common = words_a.intersection(&words_b).count();
let min_len = words_a.len().min(words_b.len());
common >= min_len / 2 + min_len % 2 // At least 50% overlap
}
fn cache_and_return(
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
key: String,
url: &str,
) {
if let Ok(mut c) = cache.lock() {
c.insert(
key,
ArtworkCacheEntry {
url: url.to_string(),
fetched_at: Instant::now(),
},
);
}
}
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
client.connect().ok()?;
Some(client)
}
/// Apply a template string, replacing placeholders with actual values.
/// Supported placeholders: {title}, {artist}, {album}
fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String {
let album_text = album.unwrap_or("");
template
.replace("{title}", title)
.replace("{artist}", artist)
.replace("{album}", album_text)
}
/// Update the Discord Rich Presence activity.
///
/// - `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.
/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}".
/// Supported placeholders: {title}, {artist}, {album}
/// - `state_template`: template string for the "state" field. Default: "{album}".
/// Supported placeholders: {title}, {artist}, {album}
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
/// Supported placeholders: {title}, {artist}, {album}
#[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,
details_template: Option<String>,
state_template: Option<String>,
large_text_template: Option<String>,
) -> 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 fetch_itunes_covers {
if let Some(ref album_name) = album {
let http_client = state.http_client.clone();
let cache = Arc::clone(&state.artwork_cache);
let artist_c = artist.clone();
let album_c = album_name.clone();
let title_c = title.clone();
tokio::task::spawn_blocking(move || {
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
})
.await
.ok()
.flatten()
} else {
None
}
} else {
None
};
let mut guard = state.client.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() {
match try_connect() {
Some(client) => *guard = Some(client),
None => return Ok(()), // Discord not running — silently skip
}
}
let client = guard.as_mut().unwrap();
// Apply templates for the three configurable text fields.
let details_str = details_template.as_deref().unwrap_or("{artist} - {title}");
let details_text = apply_template(details_str, &title, &artist, album.as_deref());
let state_str = state_template.as_deref().unwrap_or("{album}");
let state_text = apply_template(state_str, &title, &artist, album.as_deref());
let large_text_str = large_text_template.as_deref().unwrap_or("{album}");
let large_text = apply_template(large_text_str, &title, &artist, album.as_deref());
let assets = if let Some(ref url) = artwork_url {
Assets::new()
.large_image(url.as_str())
.large_text(&large_text)
} else {
// Fallback to default Psysonic icon
Assets::new()
.large_image("psysonic")
.large_text(&large_text)
};
// When paused: clear activity completely to avoid any timer issues
// When playing: show full activity with timer
if !is_playing {
if client.clear_activity().is_err() {
*guard = None;
}
return Ok(());
}
// Only reach here when playing
let activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&details_text)
.state(&state_text)
.assets(assets)
.timestamps(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;
Timestamps::new().start(start)
} else {
Timestamps::new()
});
if client.set_activity(activity).is_err() {
*guard = None;
}
Ok(())
}
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
}
}
Ok(())
}
+2541 -84
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -2,5 +2,35 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
#[cfg(target_os = "linux")]
unsafe {
if std::env::var("GDK_BACKEND").is_err() {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
}
let args: Vec<String> = std::env::args().collect();
if psysonic_lib::cli::wants_version(&args) {
psysonic_lib::cli::print_version();
return;
}
if psysonic_lib::cli::wants_help(&args) {
psysonic_lib::cli::print_help(
args.first().map(|s| s.as_str()).unwrap_or("psysonic"),
);
return;
}
if let Some(code) = psysonic_lib::cli::try_completions_dispatch(&args) {
std::process::exit(code);
}
if psysonic_lib::cli::wants_info(&args) {
psysonic_lib::cli::run_info_and_exit(&args);
}
psysonic_lib::run();
}
+276
View File
@@ -0,0 +1,276 @@
//! Windows Taskbar Thumbnail Toolbar (ITaskbarList3::ThumbBarAddButtons).
//!
//! Adds Prev / Play-Pause / Next buttons to the taskbar thumbnail preview.
//! Button clicks are intercepted via SetWindowSubclass and routed to the same
//! `media:prev`, `media:play-pause`, `media:next` events as souvlaki / tray.
use std::sync::atomic::{AtomicIsize, Ordering};
use tauri::{AppHandle, Emitter};
use windows::{
Win32::{
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
},
UI::{
Shell::{
DefSubclassProc, ITaskbarList3, RemoveWindowSubclass, SetWindowSubclass,
TaskbarList, THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBN_CLICKED,
THB_FLAGS, THB_ICON, THB_TOOLTIP,
},
WindowsAndMessaging::{
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
WM_COMMAND, WM_NCDESTROY,
},
},
},
};
// ── Embedded ICO assets ──────────────────────────────────────────────────────
static PREV_ICO: &[u8] = include_bytes!("../icons/windows/prev.ico");
static PLAY_ICO: &[u8] = include_bytes!("../icons/windows/play.ico");
static PAUSE_ICO: &[u8] = include_bytes!("../icons/windows/pause.ico");
static NEXT_ICO: &[u8] = include_bytes!("../icons/windows/next.ico");
// Button IDs — arbitrary u32 values, must fit in WPARAM low-word.
const BTN_PREV: u32 = 0xE001;
const BTN_PLAY: u32 = 0xE002;
const BTN_NEXT: u32 = 0xE003;
// Unique subclass ID.
const SUBCLASS_ID: usize = 0xC0DE_7A8B;
// Raw pointers kept as atomics so `update_taskbar_icon` can reach the
// COM object and icons without managed state.
static TASKBAR_PTR: AtomicIsize = AtomicIsize::new(0);
static HWND_VAL: AtomicIsize = AtomicIsize::new(0);
// All four HICONs stored for WM_NCDESTROY cleanup and play/pause swapping.
static HICON_PREV: AtomicIsize = AtomicIsize::new(0);
static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
// ── ICO resource loader ──────────────────────────────────────────────────────
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
///
/// Parses the ICO directory to pick the entry with the highest bit depth
/// (32 bpp = true-colour + alpha), then passes the image bits directly to
/// `CreateIconFromResourceEx`.
///
/// Note: `LookupIconIdFromDirectoryEx` operates on Win32 *resource* group-icon
/// format (GRPICONDIR), not raw `.ico` files, so we parse the ICO header ourselves.
unsafe fn load_icon_from_memory(bytes: &[u8]) -> HICON {
// ICO file layout:
// ICONDIR : reserved(2) + type(2) + count(2)
// ICONDIRENTRY[] : width(1) height(1) color_count(1) reserved(1)
// planes(2) bit_count(2) bytes_in_res(4) image_offset(4)
if bytes.len() < 6 {
return HICON::default();
}
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
if count == 0 || bytes.len() < 6 + count * 16 {
return HICON::default();
}
// Pick the entry with the highest bit depth; 32 bpp carries alpha.
let mut best_idx = 0usize;
let mut best_bpp = 0u16;
for i in 0..count {
let base = 6 + i * 16;
let bpp = u16::from_le_bytes([bytes[base + 6], bytes[base + 7]]);
if bpp >= best_bpp {
best_bpp = bpp;
best_idx = i;
}
}
let entry = &bytes[6 + best_idx * 16..];
let img_size = u32::from_le_bytes(entry[8..12].try_into().unwrap_or([0; 4]));
let img_offset = u32::from_le_bytes(entry[12..16].try_into().unwrap_or([0; 4])) as usize;
if img_size == 0 || img_offset + img_size as usize > bytes.len() {
return HICON::default();
}
CreateIconFromResourceEx(
&bytes[img_offset..img_offset + img_size as usize],
true, // fIcon = TRUE
0x0003_0000, // dwVer = 3.0 (required by the API)
0, 0, // cxDesired / cyDesired — 0 lets the system choose
LR_DEFAULTCOLOR,
)
.unwrap_or_default()
}
// ── Button descriptors ───────────────────────────────────────────────────────
fn copy_tip(dest: &mut [u16], src: &str) {
let wide: Vec<u16> = src.encode_utf16().chain(std::iter::once(0)).collect();
let len = wide.len().min(dest.len());
dest[..len].copy_from_slice(&wide[..len]);
}
unsafe fn make_buttons(
h_prev: HICON,
h_play: HICON,
h_next: HICON,
) -> [THUMBBUTTON; 3] {
let mask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
let flags = THUMBBUTTONFLAGS(0); // THBF_ENABLED
let mut prev = THUMBBUTTON::default();
prev.dwMask = mask; prev.iId = BTN_PREV;
prev.hIcon = h_prev; prev.dwFlags = flags;
copy_tip(&mut prev.szTip, "Previous");
let mut play = THUMBBUTTON::default();
play.dwMask = mask; play.iId = BTN_PLAY;
play.hIcon = h_play; play.dwFlags = flags;
copy_tip(&mut play.szTip, "Play");
let mut next = THUMBBUTTON::default();
next.dwMask = mask; next.iId = BTN_NEXT;
next.hIcon = h_next; next.dwFlags = flags;
copy_tip(&mut next.szTip, "Next");
[prev, play, next]
}
// ── WndProc subclass ─────────────────────────────────────────────────────────
struct SubclassData {
app: AppHandle,
}
unsafe extern "system" fn subclass_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
_uid: usize,
data: usize,
) -> LRESULT {
if msg == WM_COMMAND {
let hi = (wparam.0 >> 16) as u32;
let lo = (wparam.0 & 0xFFFF) as u32;
if hi == THBN_CLICKED as u32 {
if data != 0 {
let state = &*(data as *const SubclassData);
let _ = match lo {
x if x == BTN_PREV => state.app.emit("media:prev", ()),
x if x == BTN_PLAY => state.app.emit("media:play-pause", ()),
x if x == BTN_NEXT => state.app.emit("media:next", ()),
_ => Ok(()),
};
}
return LRESULT(0);
}
}
if msg == WM_NCDESTROY {
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
if data != 0 {
drop(Box::from_raw(data as *mut SubclassData));
}
let raw = TASKBAR_PTR.swap(0, Ordering::SeqCst);
if raw != 0 {
drop(Box::from_raw(raw as *mut ITaskbarList3));
}
HWND_VAL.store(0, Ordering::SeqCst);
// Destroy all stored HICONs.
for cell in [&HICON_PREV, &HICON_PLAY, &HICON_PAUSE, &HICON_NEXT] {
let h = cell.swap(0, Ordering::SeqCst);
if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); }
}
}
DefSubclassProc(hwnd, msg, wparam, lparam)
}
// ── Public init ──────────────────────────────────────────────────────────────
pub fn init(app: &AppHandle, hwnd_raw: isize) {
unsafe {
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
let hwnd = HWND(hwnd_raw as *mut _);
let taskbar: ITaskbarList3 = match CoCreateInstance(
&TaskbarList, None, CLSCTX_INPROC_SERVER,
) {
Ok(t) => t,
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
};
if let Err(e) = taskbar.HrInit() {
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
return;
}
let h_prev = load_icon_from_memory(PREV_ICO);
let h_play = load_icon_from_memory(PLAY_ICO);
let h_pause = load_icon_from_memory(PAUSE_ICO);
let h_next = load_icon_from_memory(NEXT_ICO);
// Store all HICONs for cleanup and play/pause swapping.
HICON_PREV .store(h_prev .0 as isize, Ordering::SeqCst);
HICON_PLAY .store(h_play .0 as isize, Ordering::SeqCst);
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
let mut buttons = make_buttons(h_prev, h_play, h_next);
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
return;
}
let raw = Box::into_raw(Box::new(taskbar));
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
HWND_VAL .store(hwnd_raw, Ordering::SeqCst);
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
drop(Box::from_raw(data));
}
}
}
// ── Tauri command ────────────────────────────────────────────────────────────
#[tauri::command]
pub fn update_taskbar_icon(is_playing: bool) {
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
let hwnd_raw = HWND_VAL .load(Ordering::SeqCst);
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
let icon_raw = if is_playing {
HICON_PAUSE.load(Ordering::SeqCst)
} else {
HICON_PLAY.load(Ordering::SeqCst)
};
if icon_raw == 0 { return; }
unsafe {
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
let hwnd = HWND(hwnd_raw as *mut _);
let mut btn = THUMBBUTTON::default();
btn.dwMask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
btn.iId = BTN_PLAY;
btn.hIcon = HICON(icon_raw as *mut _);
btn.dwFlags = THUMBBUTTONFLAGS(0);
copy_tip(&mut btn.szTip, if is_playing { "Pause" } else { "Play" });
let mut btns = [btn];
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
#[cfg(debug_assertions)]
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
let _ = e;
}
}
}
+11 -12
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.8.0",
"version": "1.34.12",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -16,24 +16,19 @@
"title": "Psysonic",
"width": 1280,
"height": 800,
"minWidth": 1280,
"minHeight": 720,
"minWidth": 360,
"minHeight": 480,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false
"dragDropEnabled": false,
"devtools": false
}
],
"security": {
"csp": null
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": false,
"title": "Psysonic",
"tooltip": "Psysonic"
}
},
"bundle": {
@@ -54,9 +49,13 @@
"bundleMediaFramework": true
}
},
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15"
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
"nsis": {
"installMode": "currentUser"
}
}
}
+811 -69
View File
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
import { invoke } from '@tauri-apps/api/core';
// ─── AzuraCast API types ──────────────────────────────────────────────────────
export interface AzuraCastSong {
artist: string;
title: string;
album: string;
art?: string;
text?: string; // "Artist - Title" combined
}
export interface AzuraCastNowPlayingTrack {
song: AzuraCastSong;
duration: number; // seconds
elapsed: number; // seconds played so far
remaining: number; // seconds remaining
played_at?: number;
}
export interface AzuraCastListeners {
current: number;
unique?: number;
total?: number;
}
export interface AzuraCastNowPlaying {
now_playing: AzuraCastNowPlayingTrack;
playing_next?: { song: AzuraCastSong } | null;
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
listeners: AzuraCastListeners;
station?: { name: string; shortcode: string };
}
// ─── Detection helpers ────────────────────────────────────────────────────────
/**
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
*
* AzuraCast stream URLs follow the pattern:
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
*
* Returns the candidate API URL or `null` if the pattern doesn't match.
*/
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
try {
const u = new URL(streamUrl);
const parts = u.pathname.split('/').filter(Boolean);
// Expect: ['listen', '<shortcode>', '<file>']
if (parts.length >= 2 && parts[0] === 'listen') {
const shortcode = parts[1];
return `${u.origin}/api/nowplaying/${shortcode}`;
}
} catch {
// ignore invalid URLs
}
return null;
}
/**
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
* API endpoint and return the canonical URL to use.
*
* Accepts:
* - https://<host>/api/nowplaying → all stations, we use as-is
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
*/
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
try {
const u = new URL(homepageUrl);
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
return homepageUrl;
}
} catch {
// ignore
}
return null;
}
/**
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
* the Rust backend). Returns `null` if the request fails or the response
* does not look like a valid AzuraCast payload.
*
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
* first item in the array is returned. Otherwise the single-object form is
* used directly.
*/
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
try {
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
const parsed = JSON.parse(raw);
// If the response is an array (all-stations endpoint), take the first item.
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
if (!obj || typeof obj !== 'object') return null;
const np = obj as Record<string, unknown>;
// Minimal validation: must have `now_playing` with a `song` inside.
if (
np.now_playing &&
typeof np.now_playing === 'object' &&
(np.now_playing as Record<string, unknown>).song
) {
return np as unknown as AzuraCastNowPlaying;
}
} catch {
// Network error, JSON parse error, etc.
}
return null;
}
+49
View File
@@ -0,0 +1,49 @@
export interface LrclibLyrics {
syncedLyrics: string | null;
plainLyrics: string | null;
}
export interface LrcLine {
time: number; // seconds
text: string;
}
export async function fetchLyrics(
artist: string,
title: string,
album: string,
duration: number,
): Promise<LrclibLyrics | null> {
const params = new URLSearchParams({
artist_name: artist,
track_name: title,
album_name: album,
duration: Math.round(duration).toString(),
});
try {
const res = await fetch(`https://lrclib.net/api/get?${params}`);
if (!res.ok) return null;
const data = await res.json();
return {
syncedLyrics: data.syncedLyrics ?? null,
plainLyrics: data.plainLyrics ?? null,
};
} catch {
return null;
}
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const line of lrc.split('\n')) {
// \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches.
// parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly.
const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/);
if (!match) continue;
const mins = parseInt(match[1], 10);
const secs = parseFloat(match[2]);
const text = match[3].trim();
lines.push({ time: mins * 60 + secs, text });
}
return lines.sort((a, b) => a.time - b.time);
}
+10
View File
@@ -0,0 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
try {
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
} catch {
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More