Compare commits

...

79 Commits

Author SHA1 Message Date
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
154 changed files with 28205 additions and 5903 deletions
+3 -83
View File
@@ -19,12 +19,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
@@ -80,9 +75,6 @@ jobs:
needs: create-release
permissions:
contents: write
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
strategy:
fail-fast: false
matrix:
@@ -100,12 +92,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
@@ -125,38 +112,6 @@ jobs:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: sign and upload Windows NSIS updater bundle
if: matrix.settings.platform == 'windows-latest'
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
$dir = "src-tauri/target/release/bundle/nsis"
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
$zip = $exe -replace '\.exe$', '.nsis.zip'
Compress-Archive -Path $exe -DestinationPath $zip -Force
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
- name: sign and upload macOS bundle signature
if: matrix.settings.platform == 'macos-latest'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
else
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
fi
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
cp "${BUNDLE}.sig" "$SIG_NAME"
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
build-linux:
needs: create-release
permissions:
@@ -176,13 +131,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -209,32 +158,3 @@ jobs:
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
+261
View File
@@ -5,6 +5,267 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.34.4] - 2026-04-08
### Added
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (15 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating when a song is manually skipped before a configurable playback threshold (default: 20 s). Can be enabled and adjusted in Settings → Ratings.
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
### Fixed
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
### i18n
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.3] - 2026-04-07
### Added
- **Most Played page** *(closes [#86](https://github.com/Psychotoxical/psysonic/issues/86))*: New dedicated page accessible via the sidebar (TrendingUp icon, `/most-played`). Shows **Top Artists** (ranked by total play count, derived by aggregating album play counts per artist) and a paginated **Top Albums** list with cover art, play count, sort toggle (most/fewest first), and a Load More button.
- **Playlist ZIP download** *(closes [#127](https://github.com/Psychotoxical/psysonic/issues/127))*: Download (ZIP) button in the playlist hero header — same UX as album download. Uses the Subsonic `/rest/download.view` endpoint with the playlist ID, shows a progress bar during transfer, and remembers the last used folder.
- **Fullscreen Player — adaptive accent color**: Extracts the most vibrant pixel from the current album cover (8×8 Canvas downscale, max-HSL-saturation) and applies a WCAG 4.5:1-compliant accent as `--dynamic-fs-accent`. Song title, play button, seekbar, active states, background mesh blobs, and cover art glow all transition smoothly to the extracted color. Resets to the theme accent when the player closes.
- **Dracula theme**: Added to the Open Source Classics group.
- **Discord Rich Presence — Apple Music cover opt-in**: iTunes artwork lookup is now disabled by default. A new toggle in Settings → Integrations ("Fetch covers from Apple Music for Discord") must be explicitly enabled — it sends artist and album name to Apple's search API to find cover art for the Discord profile.
- **Discord Rich Presence — Paused state**: When playback is paused, the Discord presence now shows "Paused" as the status text.
### Fixed
- **M4A playback — older iTunes-purchased files**: Files with an embedded MJPEG cover-art stream and an `iTunSMPB` gapless tag now play correctly. The Symphonia isomp4 patch skips malformed trak atoms gracefully; `parse_gapless_info` now searches for the `" 00000000 "` sentinel to skip the 16-byte binary `data`-atom header, correctly extracting encoder delay and total sample count.
### i18n
- New keys for the Most Played page, playlist download, and Discord Apple Music opt-in added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.2] - 2026-04-07
### Added
- **M4A / ALAC / AAC-LC support** *(closes [#51](https://github.com/Psychotoxical/psysonic/issues/51))*: Apple Lossless (ALAC) and AAC-LC files in M4A containers are now decoded natively by the Rust audio engine (Symphonia) without requiring server-side transcoding.
- **Per-server music folder filter** *(PR [#125](https://github.com/Psychotoxical/psysonic/pull/125) by [@cucadmuh](https://github.com/cucadmuh))*: Users with multiple music libraries on their Navidrome server can now scope browsing to a single folder. A dropdown in the sidebar (visible only when more than one library exists) lets you pick a folder or switch back to "All Libraries". The selection is persisted per server and automatically resets to "All" if the selected folder is no longer available.
- **Hi-Res / Bit-Perfect Playback** *(Alpha)*: New opt-in toggle in Settings → Playback. When enabled, the audio output stream is re-opened at the file's native sample rate (e.g. 88.2 kHz, 96 kHz) — bypassing rodio's internal resampler for a bit-perfect signal path. Disabled by default (safe 44.1 kHz mode). Includes ALSA/PipeWire underrun hardening: scaled quantum size, 500 ms sink pre-fill at high rates, and scheduler priority escalation only when needed.
- **Hot Playback Cache** *(Alpha, PR [#123](https://github.com/Psychotoxical/psysonic/pull/123) by [@cucadmuh](https://github.com/cucadmuh))*: Configurable on-disk prefetch cache for the next track in the queue. Reduces playback latency on slow or metered connections. Toggle and directory can be configured in Settings → Storage.
### Changed
- **Fullscreen Player — info block reworked**: The track title is now the dominant element (large, bold, accent color) and sits above the artist name (small, muted). Matches community feedback on visual hierarchy.
- **Fullscreen lyrics — line wrapping**: Long lyric lines now wrap onto a second line instead of being truncated. Slot height increased from 3.6 vh to 6 vh to accommodate two-line entries without breaking rail positioning.
- **Update notifications**: Removed the Tauri auto-updater (in-app download and install). The app now shows a simple dismissible toast when a newer version is detected on GitHub, with direct links to the [GitHub Releases page](https://github.com/Psychotoxical/psysonic/releases/latest) and the [Psysonic website](https://psysonic.psychotoxic.eu/#downloads). No signing keys, no update manifests.
### Fixed
- **Standard mode CPU usage**: Playing a 44.1 kHz MP3 with Hi-Res disabled no longer triggers an unnecessary audio device re-open on every track start. MSS read-ahead buffer reduced from 4 MB to 512 KB for standard-rate files. Background prefetch is now throttled by 8 s to avoid competing with playback startup. Combined, these changes reduce idle CPU from ~610 % to ~23 % on a modern machine.
- **Hi-Res toggle — stream rate not restored**: Toggling Hi-Res off while a track was playing at 88.2 or 96 kHz left the output stream at the high rate for subsequent tracks. The device's default rate is now restored on the next play.
- **Fullscreen lyrics — CPU spikes on line transitions**: Animating `font-weight` in CSS triggered a full layout reflow on every animation frame. Removed `font-weight` from the transition list; active-line emphasis now uses `transform: scaleX(1.015)` (compositor-only). Added `contain: layout style` to the overlay to isolate reflows from the rest of the page.
### i18n
- New keys for Hi-Res playback settings and music folder filter added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.1] - 2026-04-06
### Added
- **Fullscreen Player — Synced Lyrics Overlay**: Synced lyrics are now displayed directly in the Fullscreen Player as an animated 5-line rail with a soft fade mask at the top and bottom edges. Click any visible line to seek to that position. Toggle the overlay on/off with the new microphone icon button next to the heart — preference is persisted.
> **Note:** The overlay currently requires synced (timestamped) lyrics. Support for unsynced lyrics in the Fullscreen Player is planned for a future release.
- **Embedded Lyrics & LRC support**: The app now fetches lyrics from two sources using the shared `useLyrics` hook (used by both the Lyrics Pane and the Fullscreen overlay):
- **Server-embedded lyrics** via the OpenSubsonic `getLyricsBySongId` endpoint — reads timestamped or plain lyrics baked directly into the audio file's tags (Navidrome 0.53+).
- **LRCLIB** — external LRC lookup as fallback (or primary, configurable in Settings → Playback).
Both sources share a module-level cache so switching between the Lyrics Pane and the Fullscreen Player never triggers a second network request.
- **Artist Image Upload**: A camera overlay now appears when hovering the artist portrait on the Artist page. Clicking it opens a file picker and uploads the image directly to your server.
> **Requires `EnableArtworkUpload = true`** in your Navidrome configuration (new option in Navidrome [#5110](https://github.com/navidrome/navidrome/issues/5110) / [#5198](https://github.com/navidrome/navidrome/issues/5198) — default: `true`). The same requirement applies to the existing Radio Station cover upload.
- **Discord Rich Presence — Album Cover Art**: Album artwork is now displayed in Discord's Rich Presence card. Because Subsonic cover URLs require authentication (and can't be accessed by Discord directly), artwork is fetched from the iTunes Search API using a 3-strategy search (exact → relaxed → track-title fallback), cached for 1 hour, and passed as a direct URL to Discord. Falls back to the static Psysonic asset when no match is found.
- **Nightfox themes** *(PR [#112](https://github.com/Psychotoxical/psysonic/pull/112) by [@nisrael](https://github.com/nisrael))*: Six themes from the [nightfox.nvim](https://github.com/EdenEast/nightfox.nvim) palette have been added to the **Open Source Classics** group — Dawnfox, Dayfox, Nightfox, Nordfox, Carbonfox, and Terafox.
- **Auto-install script** *(PR [#121](https://github.com/Psychotoxical/psysonic/pull/121) by [@kilyabin](https://github.com/kilyabin))*: `install.sh` now supports Debian/Ubuntu (`.deb`) and RHEL/Fedora (`.rpm`) — automatically detects the distro, downloads the correct package from the latest release, and installs it.
### Changed
- **Fullscreen Player — performance overhaul**:
- `FsArt` (cover art) and `FsLyrics` are now isolated `memo` components — unrelated state changes no longer trigger their re-renders.
- Cover crossfade uses an `onLoad` DOM event instead of `new Image()` preloading. This avoids a React batching edge case where both state updates were flushed together and the browser never saw the `opacity: 0` starting state, preventing the CSS transition from firing.
- `useCachedUrl(..., true)` passes the raw URL as an immediate fallback — the image starts fetching from the network instantly while IndexedDB resolves the blob in the background.
- Lyrics slot height is stored in a `useRef` and updated only on `resize` — eliminates repeated `window.innerHeight` layout reads on every render.
- Mouse-move handler is throttled to 200 ms.
- **Artist page — biography**: The bio text is now collapsed by default with a *Read more* / *Show less* toggle button, keeping the page layout clean for artists with long bios.
- **Settings — Logout button**: Moved from the System tab to the bottom of the Server tab, styled as a danger button (red outline → red fill on hover).
### Fixed
- **Gapless playback — manual skip** *(PR [#119](https://github.com/Psychotoxical/psysonic/pull/119) by [@cucadmuh](https://github.com/cucadmuh))*: When the next track had already been gapless-pre-chained into the Sink, a manual skip would not interrupt it — the pre-chained track continued playing at full volume from the old Sink after the fade-out. The chain is now matched by stream identity so user-initiated playback always takes precedence.
- **Radio / Artist cover cache**: `invalidateCoverArt` is now called after every cover upload and delete, so the old image is evicted from the local cache immediately.
- **Queue auto-scroll**: The active track now scrolls reliably into view; eliminated unnecessary component re-renders caused by unstable selector references.
- **macOS TLS** *(PR [#114](https://github.com/Psychotoxical/psysonic/pull/114) by [@nisrael](https://github.com/nisrael))*: Switched `reqwest` from `native-tls` (macOS Security framework) to `rustls-tls` (statically linked). The native backend was returning *bad protocol version* when connecting to HTTPS music servers, silently preventing playback.
### i18n
- **Russian translation improvements** *(PR [#120](https://github.com/Psychotoxical/psysonic/pull/120) by [@kilyabin](https://github.com/kilyabin))*: Extensive phrasing refinements across the entire Russian locale.
- New keys (`fsLyricsToggle`, embedded lyrics settings) added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.0] - 2026-04-06
### Added
- **Mobile UI — Early Preview** ⚠️ — After multiple requests from the community, an initial mobile layout is shipping in this release. **This is a very early work-in-progress** — expect rough edges, missing features, and layouts that still need a lot of polish. Feedback is very welcome! Join the [Discord](https://discord.gg/ckVPGPMS) to share your thoughts.
- Sidebar and queue panel are hidden on mobile; a sticky **Bottom Navigation Bar** replaces them with quick access to Mainstage, Albums, Now Playing, and Search.
- **Mobile Player View** (`/now-playing`) — Full-screen ambient player with dynamic album-art-based background color, large cover art, track metadata line, and playback controls.
- **Mobile Search Overlay** — Full-screen search with recent search history, category chips (Albums, Artists, Genres), and grouped results.
- **Mobile Album Header** — Compact two-row icon button layout (Play + Queue primary, Favorite + Bio + Download + Offline secondary).
- **Mobile Tracklist** — Simplified track rows; disc headers preserved for multi-disc albums.
- **Mobile Hero / Carousel** — Blurred-background-only layout with circular Play + Queue buttons.
- **Russian 2 translation** *(PR [#107](https://github.com/Psychotoxical/psysonic/pull/107) by [@kilyabin](https://github.com/kilyabin))*: A second Russian translation alongside the existing one from [@cucadmuh](https://github.com/cucadmuh) *(PR [#106](https://github.com/Psychotoxical/psysonic/pull/106))*. Both are selectable in Settings → Appearance as **Russian** and **Russian 2**. Since the maintainer neither speaks nor reads Russian, **community feedback is essential here** — please vote on the [Discord](https://discord.gg/ckVPGPMS) or via GitHub which translation feels more natural so we can retire the weaker one in a future release.
- **Clickable Mainstage section headers** — "Zuletzt hinzugefügt", "Entdecken", "Künstler entdecken", and "Persönliche Favoriten" now navigate to their respective pages on click, with a `ChevronRight` indicator and accent-color hover effect.
### Fixed
- **macOS network playback** *(Issue [#108](https://github.com/Psychotoxical/psysonic/issues/108))*: Added `com.apple.security.network.client` to `Entitlements.plist` and disabled the app sandbox for unsigned/ad-hoc builds. Without this, macOS silently blocked outbound TCP connections from the Rust audio engine, causing the player to skip through every track without playing anything.
- **Auto-updater** *(under observation)*: Fixed an incorrect signature in the auto-generated `latest.json` — the CI was writing the public key as the signature value. The updater now receives a correctly signed manifest. **Note:** Due to OS-level restrictions on macOS (Gatekeeper) and Windows (SmartScreen) for unsigned apps, it is not yet certain whether the in-app updater will reliably work on these platforms. Manual installation from the Releases page remains the safe fallback.
### Changed
- All new i18n keys added to all 8 languages (EN, DE, FR, NL, ZH, NB, RU, RU2).
## [1.33.0] - 2026-04-06
### Added
- **Norwegian (Bokmål) translation** *(PR [#101](https://github.com/Psychotoxical/psysonic/pull/101) by [@zz5zz](https://github.com/zz5zz))*: Psysonic is now fully translated into Norwegian Bokmål — selectable in Settings → Appearance.
- **Configurable next-track preload** *(Issue [#102](https://github.com/Psychotoxical/psysonic/issues/102))*: A new setting in Settings → Playback controls when Psysonic starts buffering the next track. Three modes available:
- **Balanced** (default) — begins buffering 30 s before the end of the current track (previous behaviour).
- **Early** — begins buffering after just 5 s of playback, maximising reliability on slow connections.
- **Custom** — set the exact threshold (5 120 s before the end) via a slider.
- **Tray icon visibility toggle**: A new toggle in Settings → App Behavior lets you show or hide the system tray icon. When disabled, the icon is fully removed from the notification area / menu bar.
### Changed
- **Fullscreen Player — complete redesign**: The Ambient Stage has been rebuilt from the ground up.
- **Animated mesh background**: A GPU-only animated dark gradient mesh replaces the static blurred cover art background — smooth, performant, no layout repaints.
- **Artist portrait**: The right half of the screen now shows the artist's image (loaded from the server), crossfading smoothly on every track change. Falls back to the album cover if no artist image is available.
- **Bottom seekbar**: The seekbar is now pinned to the very bottom edge, spanning the full width, with elapsed and remaining timestamps above it.
- **Heart button**: You can now star/unstar the currently playing track directly from the Fullscreen Player without leaving the view.
- Removed the marquee-scrolling title in favour of a large, wrapping typographic layout.
- **Star buttons** — all star/favourite buttons across the app (Player Bar, Album Header, Album Tracklist, Queue Panel) now use the CSS class `.is-starred` instead of inline color overrides, making them trivially themeable.
### Fixed
- **macOS — HTTP audio streams**: Added `NSAppTransportSecurity` / `NSAllowsArbitraryLoads` to `Info.plist`. Without this, App Transport Security silently blocked HTTP radio streams and non-HTTPS Navidrome servers from loading audio in WKWebView on macOS.
---
## [1.32.0] - 2026-04-05 — *The Big Easter Update* 🐣
### Added
- **Custom Offline Storage Directory (#95)**: You can now specify a custom directory for the offline library in Settings → Storage & Downloads. This is perfect for offloading your internal drive to an SD card or external HDD.
- **Robust Volume Handling**: The app now automatically detects if a configured external storage medium is missing and provides a clear "Volume not found" notification instead of failing silently or attempting to download to a non-existent path.
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
- **Settings — Backup & Restore**: Export all your settings (servers, theme, font, keybindings, EQ preset, sidebar order) to a single JSON file and import them on another machine or after a reinstall. Available in Settings → Storage.
- **Albums — Year Range Filter**: A From/To year input now appears in the Albums toolbar alongside the existing genre filter. Filtering by year and by genre can be combined; clearing both inputs returns to the default view.
- **Statistics — Library Insights** *(requested via [#88](https://github.com/Psychotoxical/psysonic/issues/88))*:
- **Total Playtime** card: computed in the background by paginating your full album list (up to 5 000 albums). Shows `≥ Xh Ym` if the library is larger.
- **Genre Insights**: Top 10 genres ranked by song count with proportional progress bars.
- **Format Distribution**: Codec breakdown from a random 500-track sample — shows format name and percentage.
- **Playlist Detail — Cover Upload**: Change or remove a playlist's cover image via the hover menu that appears on the hero artwork — no external tool needed.
- **Tracklist columns — Playlists & Favorites** *(work in progress)*: PlaylistDetail and Favorites now support the same resizable, configurable column system introduced in v1.31.0 for Album tracklists. Column widths and visibility are persisted independently per page. The feature is still being refined.
### Changed
- **Crossfade — fine-grained control**: The crossfade duration slider now ranges from 0.1 s to 10 s in 0.1 s steps (previously 1 s minimum, 0.5 s steps). The current value is shown with one decimal place.
- **Settings — Storage tab redesign**: The "Offline Library" section now has a short description and includes Cache settings. The "Downloads" section is now labelled "ZIP Export & Archiving". Both sections have been visually consolidated.
- **Artists page — Load More button** *(reported via [#90](https://github.com/Psychotoxical/psysonic/issues/90))*: The button is now styled as `btn-primary` with a `ChevronDown` icon and proper spacing. Previously it was an unstyled ghost button with no visual affordance.
- **Tracklist layout consistency**: The Play-button column is now uniformly 60 px and the title column uses `minmax(150px, 1fr)` across all list views — Search Results, Artist Detail, Random Mix, and Advanced Search now match the Album tracklist layout.
- **Internet Radio — HTML5 playback**: Radio now streams via the browser's native `<audio>` element instead of a custom Rust pipeline. This improves compatibility with AAC/MP3/HLS streams.
- **AppUpdater — error visibility** *(experimental, still in progress)*: Update failures are now shown inside the update card rather than silently logged. Auto-update remains experimental — a direct GitHub Releases link is always shown as a fallback.
- **Queue panel — radio drag**: Dragging a radio station card onto the queue is now silently rejected instead of causing an error.
### Fixed
- **PlayerBar stuck on Radio info**: Switching from an Internet Radio station to a regular track no longer leaves the station name and cover in the player bar. `playTrack` now clears `currentRadio` state and stops the audio element immediately.
- **Radio favourite icon**: The heart icon is now correctly used for favourite radio stations on both the Internet Radio page and the Favourites page. It was incorrectly showing a star.
- **Offline track deletion — orphaned directories**: Deleting a cached track now removes empty parent directories up to the configured base directory. Uses `std::fs::remove_dir` (safe — only removes empty directories) to avoid accidental data loss.
---
## [1.31.0] - 2026-04-04
> **Note:** This is likely the last update for the coming week — taking a short break. See you on the other side. ☀️
### Added
- **AutoEQ — 10-Band Parametric Equalizer**: Full parametric EQ with 10 adjustable bands, bypass toggle, and pre-gain control. AutoEQ presets are loaded directly from the AutoEQ GitHub repository — search for your headphone model and apply a community-measured correction curve with one click.
- **Internet Radio — infrastructure** *(work in progress, not yet released)*: The full backend for Internet Radio playback is in place — a dedicated Rust `RadioBuffer` streaming pipeline in the audio engine, Subsonic API integration (`getInternetRadioStations`, create/update/delete), and a `playRadio` action in the player store. The UI page exists but the feature is **not yet accessible** from the sidebar — it will be enabled once the experience is polished.
- **Tracklist columns — resizable & configurable** *(experimental)*: Album tracklist columns can now be resized by dragging the dividers between header cells, similar to a spreadsheet. A column visibility picker (chevron button at the top right) lets you show or hide individual columns. The `#` column is fixed-width. Column widths and visibility are persisted in localStorage. The feature works but is still being refined.
- **Genre column in album tracklist**: Albums that have genre tags per track now show a Genre column in the tracklist.
- **Sidebar auto-migration**: New sidebar items (e.g. Internet Radio) are automatically appended to existing persisted sidebar configurations on first launch — no more missing entries after updates.
### Changed
- **Discord Rich Presence**: Activity type is now `Listening` instead of the default `Playing`. The artist field no longer has the "by " prefix — Discord's layout makes the context clear without it. Album name is shown as a tooltip on the cover icon.
- **Clickable artist names everywhere**: Artist names in Album Cards, Favorites, Random Mix, Playlist Detail, and Artist Detail tracklists are now clickable links that navigate to the artist page.
- **Duration format supports hours**: Tracks and albums longer than 60 minutes are now displayed as `H:MM:SS` instead of overflowing minutes (e.g. `75:03``1:15:03`).
- **Format column**: Codec label no longer includes the "kbps" suffix or the `·` separator — cleaner and fits the narrower column better (e.g. `FLAC 1411` instead of `FLAC · 1411 kbps`).
- **Now Playing sidebar link**: No longer permanently styled as an active menu item. It now only shows the accent background when you are actually on the Now Playing page; at all other times it is distinguished only by its accent text colour.
- **Paused-state indicator in tracklist**: When the currently active track is paused, a dimmed play icon is shown in the `#` column instead of a blank space — making it clear which track is loaded even when playback is stopped.
- **Text selection disabled**: Text can no longer be accidentally selected anywhere in the player by click-dragging or pressing Ctrl+A. Standard input fields are unaffected.
- **Settings — button styles**: "Test connection", "Add server", and "Pick download folder" buttons are now `btn-surface` (with a subtle border) instead of the borderless `btn-ghost` — clearer affordance.
- **Settings — Behavior section icon**: Replaced the generic `Sliders` icon with `AppWindow` for the Behavior section header.
- **`btn-surface` border**: The surface button variant now has a 1 px border that brightens on hover — consistent with the card and input visual language.
- **Queue panel minimum width**: Increased from 250 px to 310 px to prevent layout overflow when the codec/bitrate overlay is visible.
- **Server compatibility hint**: A short note below the Servers section header in Settings clarifies which Subsonic-compatible servers are supported.
### Fixed
- **Tracklist `#` column header alignment**: The "Select all" checkbox and the `#` symbol in the header now use the same internal layout as the row cells — ensuring alignment with individual checkboxes and track numbers at all window sizes.
- **Column resize dividers**: The visible 2 px divider line is now placed in the gap between columns rather than inside the cell, so header labels appear visually centred between their dividers.
- **Internet Radio sidebar link hidden**: The navigation entry is temporarily removed until the feature is ready for release. The underlying code remains in place and will be re-enabled without any migration required.
---
## [1.30.0] - 2026-04-03
### Added
+42 -7
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,21 +8,31 @@
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
---
<div align="center">
<a href="https://discord.gg/ckVPGPMS">
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
</a>
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
</div>
---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png)
![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
@@ -80,10 +90,35 @@ Designed specifically for users hosting their own music via Navidrome or other S
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` (NSIS installer)
### 🐧 Linux
**Quick Install (Recommended):**
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
**Manual Installation:**
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
> [!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)
+400 -3
View File
@@ -1,13 +1,14 @@
{
"name": "psysonic",
"version": "1.26.1",
"version": "1.34.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.26.1",
"version": "1.34.2",
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -35,7 +36,8 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.3"
}
},
"node_modules/@babel/code-frame": {
@@ -1226,6 +1228,40 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@@ -1585,6 +1621,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1658,6 +1712,129 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
"integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.3",
"@vitest/utils": "4.1.3",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
"integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.3",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
"integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
"integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.3",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
"integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.3",
"@vitest/utils": "4.1.3",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
"integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
"integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.3",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1756,6 +1933,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
@@ -1866,6 +2053,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1945,6 +2139,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2230,6 +2444,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2304,6 +2528,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2517,6 +2759,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2527,6 +2776,37 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2544,6 +2824,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2680,6 +2970,96 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
"integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.3",
"@vitest/mocker": "4.1.3",
"@vitest/pretty-format": "4.1.3",
"@vitest/runner": "4.1.3",
"@vitest/snapshot": "4.1.3",
"@vitest/spy": "4.1.3",
"@vitest/utils": "4.1.3",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.3",
"@vitest/browser-preview": "4.1.3",
"@vitest/browser-webdriverio": "4.1.3",
"@vitest/coverage-istanbul": "4.1.3",
"@vitest/coverage-v8": "4.1.3",
"@vitest/ui": "4.1.3",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -2689,6 +3069,23 @@
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.30.0",
"version": "1.34.4",
"private": true,
"scripts": {
"dev": "vite",
@@ -8,9 +8,11 @@
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
"tauri:build": "tauri build"
"tauri:build": "tauri build",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -38,6 +40,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.3"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.30.0
pkgver=1.34.3
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -28,7 +28,7 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

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
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
};
const platforms = {};
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
// Pull release notes from GitHub
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
+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
+193 -385
View File
@@ -69,15 +69,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -584,6 +575,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -931,17 +928,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1254,17 +1240,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1361,6 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1599,8 +1575,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1610,9 +1588,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1794,25 +1774,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1925,7 +1886,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1951,22 +1911,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1987,11 +1932,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2441,10 +2384,7 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -2486,6 +2426,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2577,18 +2523,22 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2641,23 +2591,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.8.0"
@@ -2872,7 +2805,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2888,18 +2820,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2979,50 +2899,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3049,20 +2925,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3112,7 +2974,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link 0.2.1",
]
@@ -3292,12 +3154,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
@@ -3483,12 +3339,14 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.30.0"
version = "1.34.4"
dependencies = [
"biquad",
"discord-rich-presence",
"futures-util",
"md5",
"reqwest 0.12.28",
"ringbuf",
"rodio",
"serde",
"serde_json",
@@ -3503,9 +3361,10 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thread-priority",
"tokio",
"url",
]
[[package]]
@@ -3523,6 +3382,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3569,6 +3483,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3589,6 +3513,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3607,6 +3541,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3640,15 +3583,6 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3717,30 +3651,29 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3750,6 +3683,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3766,20 +3700,15 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3829,6 +3758,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ringbuf"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rodio"
version = "0.19.0"
@@ -3909,54 +3847,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@@ -3989,15 +3889,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4055,29 +3946,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4411,7 +4279,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall 0.5.18",
"redox_syscall",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -4534,6 +4402,7 @@ dependencies = [
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
@@ -4588,6 +4457,16 @@ dependencies = [
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
@@ -4625,8 +4504,6 @@ dependencies = [
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
@@ -4723,27 +4600,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4806,17 +4662,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5072,39 +4917,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -5284,6 +5096,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread-priority"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"libc",
"log",
"rustversion",
"winapi",
]
[[package]]
name = "time"
version = "0.3.45"
@@ -5325,6 +5151,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -5351,16 +5192,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5657,6 +5488,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -5739,12 +5576,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5961,6 +5792,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@@ -6006,10 +5847,10 @@ dependencies = [
]
[[package]]
name = "webpki-root-certs"
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
@@ -6228,17 +6069,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
@@ -6804,16 +6634,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -7060,18 +6880,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+15 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.30.0"
version = "1.34.4"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -31,13 +31,24 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
url = "2"
thread-priority = "1"
[patch.crates-io]
# Local patch for Symphonia's isomp4 demuxer:
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
+11
View File
@@ -2,6 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
+9
View File
@@ -7,5 +7,14 @@
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
<!-- 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>
+5 -1
View File
@@ -17,21 +17,25 @@
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
"window-state:allow-restore-state",
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-create",
"core:webview:allow-create-webview-window",
"updater:default",
"process:allow-restart"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","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","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
-54
View File
@@ -6284,60 +6284,6 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
-54
View File
@@ -6284,60 +6284,6 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
@@ -0,0 +1 @@
{"v":1}
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
},
"path_in_vcs": "symphonia-format-isomp4"
}
+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
}
}
+1189 -74
View File
File diff suppressed because it is too large Load Diff
+273 -26
View File
@@ -1,27 +1,211 @@
/// Discord Rich Presence integration.
///
/// To enable this feature:
/// 1. Go to https://discord.com/developers/applications and create an application.
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
/// (use the app icon from public/logo.png).
/// Album artwork is fetched from the iTunes Search API and passed directly to
/// Discord via the large_image URL field. This avoids the need to pre-upload
/// assets to the Discord Developer Portal.
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
activity::{Activity, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use std::sync::Mutex;
use reqwest::blocking::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
const DISCORD_APP_ID: &str = "1489544859718258779";
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
pub struct ArtworkCacheEntry {
pub url: String,
pub fetched_at: Instant,
}
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
pub struct DiscordState {
pub client: Mutex<Option<DiscordIpcClient>>,
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
pub http_client: Client,
}
impl DiscordState {
pub fn new() -> Self {
DiscordState(Mutex::new(None))
DiscordState {
client: Mutex::new(None),
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
http_client: Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
}
// ─── iTunes Search API ───────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResponse {
results: Vec<ItunesResult>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResult {
collectionName: Option<String>,
artistName: Option<String>,
artworkUrl100: Option<String>,
}
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
fn normalize(s: &str) -> String {
s.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Search for album artwork via iTunes Search API.
/// Returns a higher-resolution URL (600x600) if found.
///
/// Takes explicit `client` and `cache` so this can be called from inside
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
fn search_itunes_artwork(
client: &Client,
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
artist: &str,
album: &str,
title: &str,
) -> Option<String> {
let cache_key = format!("{}|{}", artist, album);
// Check cache first
{
let c = cache.lock().ok()?;
if let Some(entry) = c.get(&cache_key) {
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
return Some(entry.url.clone());
}
}
}
let norm_artist = normalize(artist);
let norm_album = normalize(album);
let norm_title = normalize(title);
// Strategy 1: exact match search — "artist" "album"
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "5");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 2: relaxed search — artist album (no quotes)
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 3: search by track title — artist + title (for singles/rare albums)
if !title.is_empty() {
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, title))
.append_pair("media", "music")
.append_pair("entity", "song")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
}
None
}
fn search_with_url(
client: &Client,
url: url::Url,
norm_artist: &str,
norm_album: &str,
) -> Option<String> {
let resp = client.get(url).send().ok()?;
let body: ItunesResponse = resp.json().ok()?;
for result in &body.results {
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
// Flexible matching: check if strings contain each other
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
let artist_match = norm_artist == result_artist
|| norm_artist.contains(&result_artist)
|| result_artist.contains(&norm_artist)
|| words_overlap(norm_artist, &result_artist);
let album_match = norm_album == collection
|| norm_album.contains(&collection)
|| collection.contains(norm_album)
|| words_overlap(norm_album, &collection);
if artist_match && album_match {
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
}
}
None
}
/// Check if two strings share at least 50% of their words.
fn words_overlap(a: &str, b: &str) -> bool {
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
if words_a.is_empty() || words_b.is_empty() {
return false;
}
let common = words_a.intersection(&words_b).count();
let min_len = words_a.len().min(words_b.len());
common >= min_len / 2 + min_len % 2 // At least 50% overlap
}
fn cache_and_return(
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
key: String,
url: &str,
) {
if let Ok(mut c) = cache.lock() {
c.insert(
key,
ArtworkCacheEntry {
url: url.to_string(),
fetched_at: Instant::now(),
},
);
}
}
@@ -34,17 +218,50 @@ fn try_connect() -> Option<DiscordIpcClient> {
/// Update the Discord Rich Presence activity.
///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer.
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
/// - `elapsed_secs`: seconds already played. `None` when paused — no timestamp is sent so
/// Discord stops any running timer.
/// - `cover_art_url`: optional direct URL to album artwork.
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
/// without making any external request — required for privacy opt-in.
#[tauri::command]
pub fn discord_update_presence(
state: tauri::State<DiscordState>,
pub async fn discord_update_presence(
state: tauri::State<'_, DiscordState>,
title: String,
artist: String,
album: Option<String>,
is_playing: bool,
elapsed_secs: Option<f64>,
cover_art_url: Option<String>,
fetch_itunes_covers: bool,
) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
// run on the Tokio async executor directly.
// Only hit the iTunes API if the user has explicitly opted in.
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
Some(url)
} else if fetch_itunes_covers {
if let Some(ref album_name) = album {
let http_client = state.http_client.clone();
let cache = Arc::clone(&state.artwork_cache);
let artist_c = artist.clone();
let album_c = album_name.clone();
let title_c = title.clone();
tokio::task::spawn_blocking(move || {
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
})
.await
.ok()
.flatten()
} else {
None
}
} else {
None
};
let mut guard = state.client.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() {
@@ -56,27 +273,57 @@ pub fn discord_update_presence(
let client = guard.as_mut().unwrap();
let state_text = format!("by {artist}");
// Discord RPC only exposes two visible text rows (details + state).
// The application name "Psysonic" is shown automatically by Discord as the
// header line. Album goes into large_text — visible as a hover tooltip on
// the cover art icon.
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new()
.large_image("psysonic")
.large_text(large_text);
let assets = if let Some(ref url) = artwork_url {
Assets::new()
.large_image(url.as_str())
.large_text(large_text)
} else {
// Fallback to default Psysonic icon
Assets::new()
.large_image("psysonic")
.large_text(large_text)
};
// When paused, show "Paused" as the state text (replaces artist name).
let state_text: String = if is_playing {
artist.clone()
} else {
"Paused".to_string()
};
// ActivityType::Listening causes the Discord client to auto-start a running
// timer from "now" even when no timestamps are provided. Switch to Playing
// when paused — Playing only shows a timer when timestamps are explicitly set.
let activity_type = if is_playing {
ActivityType::Listening
} else {
ActivityType::Playing
};
let mut activity = Activity::new()
.activity_type(activity_type)
.details(&title)
.state(&state_text)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
// it so the displayed elapsed time matches the actual playback position.
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
// Only set when playing — Playing type without timestamps shows no timer.
if is_playing {
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
}
}
if client.set_activity(activity).is_err() {
@@ -91,7 +338,7 @@ pub fn discord_update_presence(
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
+605 -139
View File
@@ -6,10 +6,11 @@ mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
@@ -17,6 +18,10 @@ use tauri::{
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
@@ -31,48 +36,209 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0);
}
/// Restart after an in-app update.
///
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
/// is still alive, so the single-instance plugin in the new process sees the old
/// socket and kills itself immediately (endless loop).
///
/// This command instead:
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
/// by then the old process and its single-instance socket are fully gone.
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
/// prevent_close() and releases the single-instance lock immediately.
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
#[tauri::command]
fn relaunch_after_update(app: tauri::AppHandle) {
#[cfg(target_os = "windows")]
{
let exe = std::env::current_exe().unwrap_or_default();
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
let script = format!(
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
);
let _ = std::process::Command::new("powershell")
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
.spawn();
}
#[cfg(target_os = "macos")]
{
let exe = std::env::current_exe().unwrap_or_default();
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
let bundle = exe
.parent() // MacOS/
.and_then(|p| p.parent()) // Contents/
.and_then(|p| p.parent()); // Psysonic.app
if let Some(bundle_path) = bundle {
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
let _ = std::process::Command::new("sh")
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
.spawn();
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.set_decorations(enabled);
// Re-enabling native decorations on GTK causes the window manager to
// re-stack the window, which drops focus. Bring it back immediately.
if enabled {
let _ = win.set_focus();
}
}
}
app.exit(0);
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
#[tauri::command]
async fn upload_playlist_cover(
server_url: String,
playlist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_artist_image(
server_url: String,
artist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn delete_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command]
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/search")
.header("User-Agent", "psysonic/1.0")
.query(&[
("name", query.as_str()),
("hidebroken", "true"),
("limit", limit_s.as_str()),
("offset", offset_s.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command]
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/topvote")
.header("User-Agent", "psysonic/1.0")
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("User-Agent", "psysonic/1.0")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.split(';')
.next()
.unwrap_or("image/jpeg")
.trim()
.to_string();
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
Ok((bytes.to_vec(), content_type))
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
@@ -244,14 +410,24 @@ async fn download_track_offline(
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<String, String> {
let cache_dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id);
// Determine base cache directory.
let cache_dir = if let Some(ref cd) = custom_dir {
let base = std::path::PathBuf::from(cd);
// Check that the volume/directory is still accessible.
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
base.join(&server_id)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
};
tokio::fs::create_dir_all(&cache_dir)
.await
@@ -282,59 +458,364 @@ async fn download_track_offline(
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory.
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
let offline_dir = match app.path().app_data_dir() {
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() { return 0; }
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
if !offline_dir.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![offline_dir];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
let mut total = dir_size(default_dir);
if let Some(cd) = custom_dir {
let custom = std::path::PathBuf::from(cd);
if custom != std::path::PathBuf::from("") {
total += dir_size(custom);
}
}
total
}
/// Removes a cached track from the offline cache directory.
/// Removes a cached track from the offline cache. Accepts the full local path
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
async fn delete_offline_track(
track_id: String,
server_id: String,
suffix: String,
local_path: String,
base_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
.join(format!("{}.{}", track_id, suffix));
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
// Determine the safe boundary — never delete at or above this directory.
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
std::path::PathBuf::from(bd)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
};
// Walk upward, pruning directories that have become empty.
// Stops as soon as a non-empty directory or the boundary is reached.
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break; // Directory still has contents — stop pruning.
}
if std::fs::remove_dir(&dir).is_err() {
break; // Could not remove (e.g. permissions) — stop.
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
// ─── Hot playback cache (ephemeral; queue-based prefetch) ─────────────────────
fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
) -> Result<std::path::PathBuf, String> {
if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) {
let base = std::path::PathBuf::from(cd);
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
Ok(base.join("psysonic-hot-cache"))
} else {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-hot-cache"))
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HotCacheDownloadResult {
path: String,
size: u64,
}
/// Downloads a single track into the hot playback cache (separate from offline library).
/// Optional `custom_dir`: parent folder; files go under `<custom_dir>/psysonic-hot-cache/<server_id>/`.
/// Returns absolute path and file size for `psysonic-local://` URLs.
#[tauri::command]
async fn download_track_hot_cache(
track_id: String,
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<HotCacheDownloadResult, String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
let cache_dir = root.join(&server_id);
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
if file_path.exists() {
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
return Ok(HotCacheDownloadResult {
path: path_str,
size,
});
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
let size = bytes.len() as u64;
Ok(HotCacheDownloadResult {
path: path_str,
size,
})
}
#[tauri::command]
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
resolve_hot_cache_root(custom_dir, &app)
.map(|root| dir_size(root))
.unwrap_or(0)
}
#[tauri::command]
async fn delete_hot_cache_track(
local_path: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
if std::fs::remove_dir(&dir).is_err() {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
#[tauri::command]
async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let dir = resolve_hot_cache_root(custom_dir, &app)?;
if dir.exists() {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { stop_audio_engine(app); app.exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)
}
/// Show (`true`) or fully remove (`false`) the system-tray icon.
///
/// The command is strictly idempotent:
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
/// - `show=false` when the icon is already absent → no-op.
///
/// For removal, `set_visible(false)` is called explicitly before the handle is
/// dropped because some platforms (Windows notification area, certain Linux DEs)
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
show: bool,
) -> Result<(), String> {
let mut guard = tray_state.lock().unwrap();
if show {
// Early-return when already shown — never build a second icon.
if guard.is_some() {
return Ok(());
}
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
} else if let Some(tray) = guard.take() {
// Hide synchronously before dropping so the OS processes the removal
// before any subsequent show=true call can create a new icon.
let _ = tray.set_visible(false);
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
}
Ok(())
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
fn stop_audio_engine(app: &tauri::AppHandle) {
let engine = app.state::<audio::AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -342,7 +823,7 @@ pub fn run() {
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(TrayState::default())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init())
@@ -358,66 +839,23 @@ pub fn run() {
}))
.setup(|app| {
// ── System tray ───────────────────────────────────────────────
// ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations so the React TitleBar component
// takes over. macOS and Windows keep their native decorations.
#[cfg(target_os = "linux")]
{
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
use tauri::Manager;
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_decorations(false);
}
}
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { std::process::exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Left-click: toggle window visibility
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)?;
// ── System tray ───────────────────────────────────────────────
// Always build on startup; the frontend calls toggle_tray_icon(false)
// immediately after load if the user has disabled the tray icon.
{
let tray = build_tray_icon(app.handle())?;
*app.state::<TrayState>().lock().unwrap() = Some(tray);
}
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
@@ -515,15 +953,29 @@ pub fn run() {
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
// Let JS decide: minimize to tray or exit, based on user setting.
api.prevent_close();
let _ = window.emit("window:close-requested", ());
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Stop the audio engine first so sound cuts immediately.
let app = window.app_handle();
stop_audio_engine(app);
app.exit(0);
}
#[cfg(not(target_os = "macos"))]
{
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
}
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
exit_app,
set_window_decorations,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
@@ -536,17 +988,31 @@ pub fn run() {
audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq,
audio::autoeq_entries,
audio::autoeq_fetch_profile,
audio::audio_preload,
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
upload_playlist_cover,
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
relaunch_after_update,
download_track_hot_cache,
get_hot_cache_size,
delete_hot_cache_track,
purge_hot_cache,
toggle_tray_icon,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+3 -11
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.30.0",
"version": "1.34.4",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -16,8 +16,8 @@
"title": "Psysonic",
"width": 1280,
"height": 800,
"minWidth": 1280,
"minHeight": 720,
"minWidth": 360,
"minHeight": 480,
"resizable": true,
"fullscreen": false,
"decorations": true,
@@ -31,14 +31,6 @@
"csp": null
}
},
"plugins": {
"updater": {
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
]
}
},
"bundle": {
"active": true,
"targets": "all",
+126 -19
View File
@@ -8,6 +8,9 @@ import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
@@ -23,12 +26,14 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import MostPlayed from './pages/MostPlayed';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -46,10 +51,14 @@ import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
import { IS_LINUX } from './utils/platform';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore';
@@ -65,6 +74,18 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
const win = getCurrentWindow();
let unlisten: (() => void) | undefined;
win.onResized(() => {
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -76,9 +97,49 @@ function AppShell() {
const navigate = useNavigate();
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Sync custom titlebar preference with native decorations on Linux
useEffect(() => {
if (!IS_LINUX) return;
invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {});
}, [useCustomTitlebar]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
let cancelled = false;
(async () => {
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
try {
const folders = await getMusicFolders();
if (stillThisServer()) setMusicFolders(folders);
} catch {
if (stillThisServer()) setMusicFolders([]);
}
try {
const level = await probeEntityRatingSupport();
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
} catch {
if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only');
}
})();
return () => {
cancelled = true;
};
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
// Reset scroll position on route change
useEffect(() => {
document.querySelector('.content-body')?.scrollTo({ top: 0 });
}, [location.pathname]);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
useEffect(() => {
@@ -141,7 +202,7 @@ function AppShell() {
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingQueue]);
@@ -185,30 +246,60 @@ function AppShell() {
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
const blockSelectAll = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
// Allow Ctrl+A inside actual text inputs and textareas
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
}
};
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
const blockSelectStart = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if ((target as HTMLElement).closest('[data-selectable]')) return;
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
<div
className="app-shell"
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined}
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && <TitleBar />}
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
)}
<main className="main-content">
<header className="content-header">
<LiveSearch />
@@ -250,7 +341,8 @@ function AppShell() {
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
@@ -258,19 +350,23 @@ function AppShell() {
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
</Routes>
</div>
</main>
<div
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
/>
<QueuePanel />
<PlayerBar />
{!isMobile && (
<div
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
/>
)}
{!isMobile && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />}
{isFullscreenOpen && (
<FullscreenPlayer onClose={toggleFullscreen} />
)}
@@ -290,6 +386,13 @@ function TauriEventBridge() {
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -417,6 +520,10 @@ export default function App() {
return initAudioListeners();
}, []);
useEffect(() => {
return initHotCachePrefetch();
}, []);
useEffect(() => {
useGlobalShortcutsStore.getState().registerAll();
}, []);
+357 -6
View File
@@ -1,5 +1,6 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
@@ -39,6 +40,15 @@ async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, tim
return data as T;
}
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
export function libraryFilterParams(): Record<string, string | number> {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return {};
const f = musicLibraryFilterByServer[activeServerId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
}
// ─── Types ────────────────────────────────────────────────────
export interface SubsonicAlbum {
id: string;
@@ -48,11 +58,21 @@ export interface SubsonicAlbum {
coverArt?: string;
songCount: number;
duration: number;
playCount?: number;
year?: number;
genre?: string;
starred?: string;
recordLabel?: string;
created?: string;
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
userRating?: number;
}
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
export interface SubsonicOpenArtistRef {
id?: string;
name?: string;
userRating?: number;
}
export interface SubsonicSong {
@@ -68,6 +88,11 @@ export interface SubsonicSong {
coverArt?: string;
year?: number;
userRating?: number;
/** Some OpenSubsonic responses attach parent ratings on child songs. */
albumUserRating?: number;
artistUserRating?: number;
artists?: SubsonicOpenArtistRef[];
albumArtists?: SubsonicOpenArtistRef[];
// Audio technical info
bitRate?: number;
suffix?: string;
@@ -88,6 +113,22 @@ export interface SubsonicSong {
};
}
export interface InternetRadioStation {
id: string;
name: string;
streamUrl: string;
homepageUrl?: string;
coverArt?: string; // Navidrome v0.61.0+
}
export interface RadioBrowserStation {
stationuuid: string;
name: string;
url: string;
favicon: string;
tags: string;
}
export interface SubsonicPlaylist {
id: string;
name: string;
@@ -97,6 +138,7 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
comment?: string;
coverArt?: string;
}
@@ -113,6 +155,8 @@ export interface SubsonicArtist {
albumCount?: number;
coverArt?: string;
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
}
export interface SubsonicGenre {
@@ -121,6 +165,11 @@ export interface SubsonicGenre {
albumCount: number;
}
export interface SubsonicMusicFolder {
id: string;
name: string;
}
export interface SubsonicArtistInfo {
biography?: string;
musicBrainzId?: string;
@@ -132,6 +181,19 @@ export interface SubsonicArtistInfo {
}
// ─── API Methods ──────────────────────────────────────────────
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view',
);
const raw = data.musicFolders?.musicFolder;
if (!raw) return [];
const arr = Array.isArray(raw) ? raw : [raw];
return arr.map(f => ({
id: String((f as { id: string | number }).id),
name: (f as { name?: string }).name ?? 'Library',
}));
}
export async function ping(): Promise<boolean> {
try {
await api('ping.view');
@@ -160,7 +222,11 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
...libraryFilterParams(),
});
return data.albumList2?.album ?? [];
}
@@ -170,12 +236,19 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
...extra,
});
return data.albumList2?.album ?? [];
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size, _t: Date.now() };
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? [];
@@ -196,8 +269,75 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
return { album, songs: song ?? [] };
}
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
}
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
export async function prefetchAlbumUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view');
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),
});
const indices = data.artists?.index ?? [];
return indices.flatMap(i => i.artist ?? []);
}
@@ -240,7 +380,12 @@ export async function getGenres(): Promise<SubsonicGenre[]> {
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(),
type: 'byGenre',
genre,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
});
const raw = data.albumList2?.album;
if (!raw) return [];
@@ -266,7 +411,7 @@ export async function getStarred(): Promise<StarredResults> {
album?: SubsonicAlbum[];
song?: SubsonicSong[];
}
}>('getStarred2.view');
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
@@ -300,6 +445,7 @@ export async function search(query: string, options?: { albumCount?: number; art
artistCount: options?.artistCount ?? 5,
albumCount: options?.albumCount ?? 5,
songCount: options?.songCount ?? 10,
...libraryFilterParams(),
});
const r = data.searchResult3 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
@@ -309,6 +455,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
}
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
export type EntityRatingSupportLevel = 'track_only' | 'full';
/**
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
*/
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
try {
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
'getOpenSubsonicExtensions.view',
{},
8000,
);
if (data.openSubsonic === true) return 'full';
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
return 'track_only';
} catch {
return 'track_only';
}
}
export async function scrobbleSong(id: string, time: number): Promise<void> {
try {
await api('scrobble.view', { id, time, submission: true });
@@ -409,6 +577,49 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
}
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
@@ -442,3 +653,143 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
return [];
}
}
// ─── Internet Radio ───────────────────────────────────────────
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export const RADIO_PAGE_SIZE = 25;
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
// ─── Structured Lyrics (OpenSubsonic / getLyricsBySongId) ────────────────────
export interface SubsonicLyricLine {
start?: number; // milliseconds — absent when unsynced
value: string;
}
export interface SubsonicStructuredLyrics {
issynced: boolean;
lang?: string;
offset?: number;
displayArtist?: string;
displayTitle?: string;
line: SubsonicLyricLine[];
}
/**
* Fetches structured lyrics from the server's embedded tags via the
* OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the
* server doesn't support the endpoint or the track has no embedded lyrics.
* Prefers synced lyrics over plain when both are present.
*/
export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredLyrics | null> {
try {
const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>(
'getLyricsBySongId.view',
{ id },
);
const list = data.lyricsList?.structuredLyrics;
if (!list || list.length === 0) return null;
return list.find(l => l.issynced) ?? list[0];
} catch {
// Server doesn't support the endpoint or track has no embedded lyrics
return null;
}
}
+45 -17
View File
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, HardDriveDownload } from 'lucide-react';
import { Play, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -11,9 +11,13 @@ import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
showRating?: boolean;
}
function AlbumCard({ album }: AlbumCardProps) {
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -25,20 +29,26 @@ function AlbumCard({ album }: AlbumCardProps) {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
const handleClick = () => {
if (selectionMode) { onToggleSelect?.(album.id); return; }
navigate(`/album/${album.id}`);
};
return (
<div
className="album-card card"
onClick={() => navigate(`/album/${album.id}`)}
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
if (selectionMode) { e.preventDefault(); return; }
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
onMouseDown={e => {
if (e.button !== 0) return;
if (selectionMode || e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
@@ -64,25 +74,43 @@ function AlbumCard({ album }: AlbumCardProps) {
</svg>
</div>
)}
{isOffline && (
{isOffline && !selectionMode && (
<div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
{selectionMode && (
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
</div>
)}
{!selectionMode && (
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
)}
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
{showRating && (album.userRating ?? 0) > 0 && (
<div className="album-card-rating-row">
<span className="album-card-rating-stars">
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
</span>
</div>
)}
</div>
</div>
);
+174 -68
View File
@@ -5,10 +5,15 @@ import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
@@ -82,6 +87,10 @@ interface AlbumHeaderProps {
onEnqueueAll: () => void;
onBio: () => void;
onCloseBio: () => void;
entityRatingValue: number;
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
}
export default function AlbumHeader({
@@ -104,9 +113,13 @@ export default function AlbumHeader({
onEnqueueAll,
onBio,
onCloseBio,
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [lightboxOpen, setLightboxOpen] = useState(false);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
@@ -182,73 +195,166 @@ export default function AlbumHeader({
</>
)}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-ghost offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
<div className="album-detail-entity-rating">
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
<div className="album-actions-row album-actions-row--primary">
<button
className="album-icon-btn album-icon-btn--play"
onClick={onPlayAll}
aria-label={t('albumDetail.playAll')}
data-tooltip={t('albumDetail.playAll')}
>
<Play size={24} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={onEnqueueAll}
aria-label={t('albumDetail.enqueue')}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
aria-label={t('albumDetail.artistBio')}
data-tooltip={t('albumDetail.artistBio')}
>
<ExternalLink size={16} />
</button>
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)}
</div>
</div>
) : (
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-ghost offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
</div>
)}
</div>
</div>
</div>
+12 -4
View File
@@ -2,18 +2,20 @@ import React, { useRef, useState, useEffect } from 'react';
import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
titleLink?: string;
albums: SubsonicAlbum[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
showRating?: boolean;
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@@ -61,7 +63,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
{titleLink ? (
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
{title}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
) : (
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
@@ -82,7 +90,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+277 -91
View File
@@ -1,46 +1,52 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Play, Heart, ListPlus, X } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
if (song.bitRate) parts.push(`${song.bitRate}`);
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const { t } = useTranslation();
const [hover, setHover] = React.useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where header label is centred in the cell (matches row controls below)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps {
songs: SubsonicSong[];
@@ -48,6 +54,8 @@ interface AlbumTrackListProps {
currentTrack: Track | null;
isPlaying: boolean;
ratings: Record<string, number>;
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
@@ -61,6 +69,7 @@ export default function AlbumTrackList({
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onRate,
@@ -68,15 +77,24 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
@@ -99,7 +117,6 @@ export default function AlbumTrackList({
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Close playlist picker on outside click
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
@@ -110,8 +127,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -123,8 +138,198 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
const isResizable = !isLastCol;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) {
case 'num':
return (
<div
key="num"
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
{currentTrack?.id === song.id && isPlaying && (
<span className="track-num-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
)}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{song.track ?? '—'}</span>
</div>
);
case 'title':
return (
<div key="title" className="track-info">
<span className="track-title">{song.title}</span>
</div>
);
case 'artist':
return (
<div key="artist" className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>
{song.artist}
</span>
</div>
);
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
<button
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
default:
return null;
}
};
// ── Mobile tracklist ─────────────────────────────────────────────────────
if (isMobile) {
return (
<div className="tracklist-mobile">
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span> CD {discNum}
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrack?.id === song.id;
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="tracklist-mobile-main">
{isActive && isPlaying ? (
<span className="tracklist-mobile-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
))}
</div>
);
}
return (
<div className="tracklist">
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
@@ -158,20 +363,46 @@ export default function AlbumTrackList({
</div>
)}
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">
{inSelectMode
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
: '#'}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
</span>
{label}
</button>
);
})}
</div>
)}
</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
@@ -186,6 +417,7 @@ export default function AlbumTrackList({
<div
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
@@ -216,59 +448,13 @@ export default function AlbumTrackList({
document.addEventListener('mouseup', onUp);
}}
>
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
);
})}
</div>
))}
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+21 -95
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell';
import { RefreshCw, Download, X } from 'lucide-react';
import { RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
@@ -18,83 +16,31 @@ function isNewer(a: string, b: string): boolean {
return false;
}
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
export default function AppUpdater() {
const { t } = useTranslation();
const [state, setState] = useState<State>({ phase: 'idle' });
const [newVersion, setNewVersion] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
if (cancelled) return;
try {
// Try Tauri native updater first (macOS + Windows)
const update = await check();
if (cancelled) return;
if (update) {
setState({ phase: 'available', version: update.version, update });
return;
}
} catch {
// Tauri updater unavailable or network error — fall through to GitHub check
}
// Fallback: GitHub API check (Linux / offline Tauri updater)
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok || cancelled) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, currentVersion)) {
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
setNewVersion(tag.replace(/^[^0-9]*/, ''));
}
} catch {
// No update check possible — stay idle
// No network or rate-limited — stay idle
}
}, 3000);
}, 4000);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
const handleInstall = async () => {
if (state.phase !== 'available' || !state.update) return;
const update = state.update;
const savedVersion = state.version;
let total = 0;
let downloaded = 0;
setState({ phase: 'downloading', pct: 0 });
try {
await update.downloadAndInstall((event: DownloadEvent) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
} else if (event.event === 'Finished') {
setState({ phase: 'installing' });
}
});
await invoke('relaunch_after_update');
} catch (e) {
console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update });
}
};
const handleDownload = () => {
open('https://github.com/Psychotoxical/psysonic/releases/latest');
};
const version = state.phase === 'available' ? state.version : '';
const canInstall = state.phase === 'available' && state.update !== null;
const isLinuxFallback = state.phase === 'available' && state.update === null;
if (!newVersion || dismissed) return null;
return createPortal(
<div className="app-updater-toast">
@@ -105,41 +51,21 @@ export default function AppUpdater() {
<X size={12} />
</button>
</div>
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
{state.phase === 'downloading' && (
<div className="app-updater-progress-wrap">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
</div>
<span className="app-updater-pct">{state.pct}%</span>
</div>
)}
{state.phase === 'installing' && (
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
)}
{state.phase === 'available' && (
<div className="app-updater-actions">
{canInstall && (
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<button className="app-updater-btn-secondary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
</>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
)}
</div>
)}
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
<div className="app-updater-actions">
<button
className="app-updater-btn-primary"
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
>
GitHub
</button>
<button
className="app-updater-btn-secondary"
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
>
{t('common.updaterWebsite')}
</button>
</div>
</div>,
document.body
);
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import MobileSearchOverlay from './MobileSearchOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
] as const;
export default function BottomNav() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
return (
<>
<nav className="bottom-nav" aria-label="Mobile navigation">
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
>
<span className="bottom-nav-icon-wrap">
<Icon size={22} />
{to === '/now-playing' && isPlaying && currentTrack && (
<span className="bottom-nav-np-dot" />
)}
</span>
<span className="bottom-nav-label">{t(labelKey)}</span>
</NavLink>
))}
<button
className="bottom-nav-item"
onClick={() => setSearchOpen(true)}
aria-label={t('search.title')}
>
<span className="bottom-nav-icon-wrap">
<Search size={22} />
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
</>
);
}
+17 -2
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -381,6 +382,13 @@ export default function ContextMenu() {
);
})()}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
@@ -501,6 +509,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
+186 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
@@ -147,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
@@ -182,20 +183,57 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
);
}
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
/** Parses AutoEQ FixedBandEQ.txt format.
* Expected lines:
* Preamp: -5.5 dB
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
* ...
* Returns all 10 band gains as exact floats and the preamp value.
*/
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
allFilters.slice(0, 10).forEach((m, i) => {
gains[i] = parseFloat(m[1]);
});
return { gains, preamp };
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const preGain = useEqStore(s => s.preGain);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// AutoEQ state
const [autoEqOpen, setAutoEqOpen] = useState(false);
const [autoEqQuery, setAutoEqQuery] = useState('');
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
const [autoEqLoading, setAutoEqLoading] = useState(false);
const [autoEqError, setAutoEqError] = useState<string | null>(null);
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
const [entriesLoading, setEntriesLoading] = useState(false);
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
const theme = useThemeStore(s => s.theme);
const redraw = useCallback(() => {
@@ -216,6 +254,62 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
// AutoEQ: load entries index lazily when section opens, then filter client-side
async function ensureEntries() {
if (entriesCacheRef.current) return;
setEntriesLoading(true);
setAutoEqError(null);
try {
const json = await invoke<string>('autoeq_entries');
entriesCacheRef.current = JSON.parse(json);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
} finally {
setEntriesLoading(false);
}
}
useEffect(() => {
const q = autoEqQuery.trim().toLowerCase();
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
const flat: AutoEqResult[] = [];
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
if (!name.toLowerCase().includes(q)) continue;
for (const v of variants) {
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
if (flat.length >= 20) break;
}
if (flat.length >= 20) break;
}
setAutoEqResults(flat);
// entriesLoading in deps: re-runs after entries finish loading so a query typed
// during loading produces results immediately without needing a re-type.
}, [autoEqQuery, entriesLoading]);
async function applyAutoEqResult(result: AutoEqResult) {
setAutoEqLoading(true);
setAutoEqError(null);
try {
const text = await invoke<string>('autoeq_fetch_profile', {
name: result.name,
source: result.source,
rig: result.rig ?? null,
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
setTimeout(() => setAutoEqApplied(null), 3000);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
} finally {
setAutoEqLoading(false);
}
}
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
@@ -279,6 +373,74 @@ export default function Equalizer() {
</div>
)}
{/* AutoEQ section */}
<div className="eq-autoeq-section">
<button
className="eq-autoeq-toggle"
onClick={() => {
const opening = !autoEqOpen;
setAutoEqOpen(opening);
setAutoEqQuery('');
setAutoEqResults([]);
setAutoEqError(null);
if (opening) ensureEntries();
}}
>
<Search size={13} />
<span>{t('settings.eqAutoEqTitle')}</span>
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{autoEqOpen && (
<div className="eq-autoeq-body">
<div className="eq-autoeq-search-row">
<input
className="input"
placeholder={t('settings.eqAutoEqPlaceholder')}
value={autoEqQuery}
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
autoFocus
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
/>
{autoEqQuery && (
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
<X size={13} />
</button>
)}
</div>
{(entriesLoading || autoEqLoading) && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
)}
{autoEqError && (
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
)}
{autoEqApplied && (
<div className="eq-autoeq-status eq-autoeq-applied"> {autoEqApplied}</div>
)}
{autoEqResults.length > 0 && (
<div className="eq-autoeq-results">
{autoEqResults.map((r, i) => (
<button
key={`${r.name}|${r.source}|${i}`}
className="eq-autoeq-result-btn"
onClick={() => applyAutoEqResult(r)}
>
<span>{r.name}</span>
<span className="eq-autoeq-result-source">{r.source}</span>
</button>
))}
</div>
)}
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
)}
</div>
)}
</div>
{/* EQ panel */}
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
{/* Frequency response */}
@@ -313,6 +475,27 @@ export default function Equalizer() {
</div>
))}
</div>
{/* Pre-gain row */}
<div className="eq-pregain-row">
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
<input
type="range"
className="eq-pregain-slider"
min={-30} max={6} step={0.1}
value={preGain}
disabled={!enabled}
onChange={e => setPreGain(parseFloat(e.target.value))}
/>
<span className="eq-pregain-val">
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
</span>
{preGain !== 0 && (
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
<RotateCcw size={11} />
</button>
)}
</div>
</div>
</div>
);
+348 -141
View File
@@ -1,13 +1,18 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -16,88 +21,179 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window:
// activeIdx=0 → railY=+2×slotH (line 0 at slot 2)
// activeIdx=2 → railY=0 (line 2 at center)
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, loading } = useLyrics(currentTrack);
const lines = syncedLines as LrcLine[] | null;
const hasSynced = lines !== null && lines.length > 0;
// Keep a ref so the zustand selector can read lines without closing over
// a changing variable — avoids re-creating the selector on every render.
const linesRef = useRef<LrcLine[]>([]);
linesRef.current = hasSynced ? lines! : [];
// Selector returns the active line INDEX — zustand only re-renders when it
// actually changes, dropping us from ~10 Hz to ~0.2 Hz re-renders.
const activeIdx = usePlayerStore(s => {
const ls = linesRef.current;
if (ls.length === 0) return -1;
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
});
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
const slotH = useRef(window.innerHeight * 0.06);
useEffect(() => {
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
// Event delegation — one handler for all lyric lines instead of N closures per tick.
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
if (!target || duration <= 0) return;
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
if (!currentTrack || loading || !hasSynced) return null;
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
<div className="fs-lyrics-overlay" aria-hidden="true">
<div
className="fs-lyrics-rail"
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
{title}
</span>
{lines!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))}
</div>
</div>
);
}
});
// ─── Crossfading blurred background ──────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counterRef = useRef(1);
// ─── Album art box — crossfades layers so old art stays visible while new loads
// Uses 300px thumbnails (portrait fallback uses 500px separately).
//
// Why onLoad instead of new Image() preload:
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
// before we flip it to 1.
const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
// true = show raw fetchUrl immediately as fallback while blob resolves.
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
// Showing the URL directly avoids the multi-second blank wait.
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
const counter = useRef(0);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Add a new invisible layer whenever the blob URL changes.
useEffect(() => {
if (!url) return;
let cancelled = false;
const id = counterRef.current++;
// Preload the image before starting the crossfade — prevents a blank flash
// between the old and new layer while the browser decodes the image.
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
setLayers(prev => [...prev, { url, id, visible: false }]);
requestAnimationFrame(() => {
if (cancelled) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
setTimeout(() => {
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
}, 800);
});
};
img.src = url;
return () => { cancelled = true; };
}, [url]);
if (!blobUrl) return;
const id = ++counter.current;
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
}, [blobUrl]);
// Called by the DOM <img> once it has painted at opacity:0 — now safe to transition.
// Cancel any pending cleanup timer so a stale setTimeout from a previous layer
// cannot remove the layer we are making visible right now.
const handleLoad = useCallback((id: number) => {
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
}, []);
if (layers.length === 0) {
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
}
return (
<>
{layers.map(layer => (
<div
key={layer.id}
className="fs-bg"
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
aria-hidden="true"
{layers.map(l => (
<img
key={l.id}
src={l.src}
className="fs-art"
style={{ opacity: l.vis ? 1 : 0 }}
onLoad={() => handleLoad(l.id)}
alt=""
decoding="async"
/>
))}
</>
);
});
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
// ─── Artist portrait — right half, crossfades on track change ─────────────────
const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counterRef = useRef(1);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!url) return;
let cancelled = false;
const id = counterRef.current++;
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
setLayers(prev => [...prev, { url, id, visible: false }]);
requestAnimationFrame(() => {
if (cancelled) return;
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
cleanupTimer.current = setTimeout(() => {
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
}, 1000);
});
};
img.src = url;
return () => { cancelled = true; };
}, [url]);
if (layers.length === 0) return null;
return (
<div className="fs-portrait-wrap" aria-hidden="true">
{layers.map(layer => (
<img
key={layer.id}
src={layer.url}
className="fs-portrait"
style={{ opacity: layer.visible ? 1 : 0 }}
decoding="async"
loading="eager"
alt=""
/>
))}
</div>
);
});
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
@@ -112,21 +208,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
aria-label="seek"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
@@ -150,28 +247,64 @@ interface FullscreenPlayerProps {
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// buildCoverArtUrl generates a new salt on every call — must be memoized
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// No fetchUrl fallback for the background — we only want stable blob URLs
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
// buildCoverArtUrl generates a new salt on every call — must be memoized.
// 300px for the small art box; 500px for the right-side portrait fallback.
const artUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]);
const artKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]);
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Fetch artist image for background — fall back to cover art if unavailable
// Dynamic accent color extracted from the current album cover.
// Applied as --dynamic-fs-accent on the root element so it inherits to all
// children; CSS rules use var(--dynamic-fs-accent, var(--accent)) as fallback.
// Reset to null on track change so the previous color doesn't linger while
// the new one is being extracted.
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
useEffect(() => {
setDynamicAccent(null);
if (!artUrl || !artKey) return;
let cancelled = false;
getCachedUrl(artUrl, artKey).then(blobUrl => {
if (cancelled || !blobUrl) return;
extractCoverColors(blobUrl).then(colors => {
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
});
});
return () => { cancelled = true; };
}, [artKey]); // artKey is stable per track — artUrl would also work
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
@@ -184,92 +317,166 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => { cancelled = true; };
}, [currentTrack?.artistId]);
const bgUrl = artistBgUrl || resolvedCoverUrl;
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
// Pre-fetch next track's 300px cover into the IndexedDB cache.
// Selector returns only the coverArt id, so it only re-runs on actual changes.
const nextCoverArt = usePlayerStore(s => {
const q = s.queue;
const idx = s.queueIndex;
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null;
});
useEffect(() => {
if (!nextCoverArt) return;
const url = buildCoverArtUrl(nextCoverArt, 300);
const key = coverArtCacheKey(nextCoverArt, 300);
getCachedUrl(url, key).catch(() => {});
}, [nextCoverArt]);
// Idle-fade system — hides controls after 3 s of inactivity
const [isIdle, setIsIdle] = useState(false);
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetIdle = useCallback(() => {
setIsIdle(false);
if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => setIsIdle(true), 3000);
}, []);
// Throttled wrapper for mousemove — avoids clearing/setting timeouts on every pixel.
const lastMoveTime = useRef(0);
const handleMouseMove = useCallback(() => {
const now = Date.now();
if (now - lastMoveTime.current < 200) return;
lastMoveTime.current = now;
resetIdle();
}, [resetIdle]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
resetIdle();
return () => { if (idleTimer.current) clearTimeout(idleTimer.current); };
}, [resetIdle]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
resetIdle();
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
}, [onClose, resetIdle]);
const metaParts = useMemo(() => [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean), [currentTrack]);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
<div
className="fs-player"
role="dialog"
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
onMouseMove={handleMouseMove}
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
>
{/* Layer 1blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 0animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
<FsPortrait url={portraitUrl} />
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Lyrics overlay — upper-left quadrant, above cluster */}
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<div className="fs-cover-wrap">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverKey}
alt={`${currentTrack?.album} Cover`}
className="fs-cover"
/>
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
{/* Album art */}
<div className="fs-art-wrap">
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
<div className="fs-track-info">
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
].filter(Boolean).join(' · ')}
</span>
)}
</div>
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
<FsProgress duration={duration} />
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
<Square size={14} fill="currentColor" />
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={20} />
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={20} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
className="fs-btn fs-btn-sm"
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+85 -32
View File
@@ -6,8 +6,14 @@ import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32;
// Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) {
@@ -50,14 +56,35 @@ interface HeroProps {
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, [albumsProp]);
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
getRandomAlbums(pool)
.then(async raw => {
const list = albumMix
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
: raw;
setAlbums(list);
})
.catch(() => {});
}, [
albumsProp,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
// Start / restart auto-advance timer
const startTimer = useCallback((len: number) => {
@@ -120,7 +147,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
{coverRawUrl && (
{coverRawUrl && !isMobile && (
<CachedImage
className="hero-cover"
src={coverRawUrl}
@@ -135,36 +162,62 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
<div className="hero-meta">
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
{t('hero.enqueue')}
</button>
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button
className="album-icon-btn album-icon-btn--play"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={22} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={async e => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
aria-label={t('hero.enqueue')}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
{t('hero.enqueue')}
</button>
</div>
)}
</div>
</div>
+3 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -24,6 +25,7 @@ export default function LiveSearch() {
const playTrack = usePlayerStore(state => state.playTrack);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const doSearch = useCallback(
debounce(async (q: string) => {
@@ -37,7 +39,7 @@ export default function LiveSearch() {
setLoading(false);
}
}, 300),
[]
[musicLibraryFilterVersion]
);
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
+19 -69
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import type { LrcLine } from '../api/lrclib';
import { useLyrics } from '../hooks/useLyrics';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
@@ -8,87 +9,27 @@ interface Props {
currentTrack: Track | null;
}
interface CachedLyrics {
syncedLines: LrcLine[] | null;
plainLyrics: string | null;
notFound: boolean;
}
// Session-level cache — survives tab switches (component unmount/remount).
// Cleared implicitly when the app restarts.
const lyricsCache = new Map<string, CachedLyrics>();
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const { syncedLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1);
// Reset refs when track changes
useEffect(() => {
if (!currentTrack) return;
// Serve from cache if available
const hit = lyricsCache.get(currentTrack.id);
if (hit) {
setSyncedLines(hit.syncedLines);
setPlainLyrics(hit.plainLyrics);
setNotFound(hit.notFound);
setLoading(false);
lineRefs.current = [];
prevActive.current = -1;
return;
}
let cancelled = false;
setSyncedLines(null);
setPlainLyrics(null);
setNotFound(false);
setLoading(true);
lineRefs.current = [];
prevActive.current = -1;
fetchLyrics(
currentTrack.artist ?? '',
currentTrack.title,
currentTrack.album ?? '',
currentTrack.duration ?? 0,
).then(result => {
if (cancelled) return;
setLoading(false);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setNotFound(true);
return;
}
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
const synced = lines && lines.length > 0 ? lines : null;
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
setSyncedLines(synced);
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setLoading(false);
setNotFound(true);
}
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
? (syncedLines as LrcLine[]).reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
useEffect(() => {
@@ -112,13 +53,19 @@ export default function LyricsPane({ currentTrack }: Props) {
return `${base} active`;
};
const sourceLabel = source === 'server'
? t('player.lyricsSourceServer')
: source === 'lrclib'
? t('player.lyricsSourceLrclib')
: null;
return (
<div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
{hasSynced && (
<div className="lyrics-synced">
{syncedLines!.map((line, i) => (
{(syncedLines as LrcLine[]).map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
@@ -138,6 +85,9 @@ export default function LyricsPane({ currentTrack }: Props) {
))}
</div>
)}
{sourceLabel && !loading && !notFound && (
<p className="lyrics-source">{sourceLabel}</p>
)}
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
// ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
function extractVibrantColor(imageUrl: string): Promise<string> {
return new Promise(resolve => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve('0,0,0'); return; }
ctx.drawImage(img, 0, 0, 16, 16);
const { data } = ctx.getImageData(0, 0, 16, 16);
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const max = Math.max(r, g, b) / 255;
const min = Math.min(r, g, b) / 255;
const l = (max + min) / 2;
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
// Prefer saturated pixels in the medium-dark range (l 0.20.6)
const score = s * (1 - Math.abs(l - 0.4));
if (score > bestScore) {
bestScore = score;
bestR = r; bestG = g; bestB = b;
}
}
resolve(`${bestR},${bestG},${bestB}`);
};
img.onerror = () => resolve('0,0,0');
img.src = imageUrl;
});
}
function useAlbumAccentColor(imageUrl: string): string {
const [color, setColor] = useState('0,0,0');
useEffect(() => {
if (!imageUrl) { setColor('0,0,0'); return; }
let cancelled = false;
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
return () => { cancelled = true; };
}, [imageUrl]);
return color;
}
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ── Queue Drawer ──────────────────────────────────────────────────────────────
function QueueDrawer({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playTrack = usePlayerStore(s => s.playTrack);
const listRef = useRef<HTMLDivElement>(null);
// Scroll active track into view on open
useEffect(() => {
const el = listRef.current?.querySelector('.mq-item.active');
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
}, []);
return (
<div className="mq-drawer-backdrop" onClick={onClose}>
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
<div className="mq-drawer-header">
<h3>{t('queue.title')}</h3>
<span className="mq-drawer-count">
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
</span>
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="mq-drawer-list" ref={listRef}>
{queue.length === 0 ? (
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
) : (
queue.map((track, idx) => {
const isActive = idx === queueIndex;
return (
<div
key={`${track.id}-${idx}`}
className={`mq-item${isActive ? ' active' : ''}`}
onClick={() => { playTrack(track, queue); onClose(); }}
>
<div className="mq-item-info">
<div className="mq-item-title">
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="mq-item-artist truncate">{track.artist}</div>
</div>
<span className="mq-item-dur">{formatTime(track.duration)}</span>
</div>
);
})
)}
</div>
</div>
</div>
);
}
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
const { t } = useTranslation();
return (
<div className="mq-drawer-backdrop" onClick={onClose}>
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
<div className="mq-drawer-header">
<h3>{t('player.lyrics')}</h3>
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="mq-drawer-list">
<LyricsPane currentTrack={currentTrack} />
</div>
</div>
</div>
);
}
// ── Mobile Player View ────────────────────────────────────────────────────────
export default function MobilePlayerView() {
const { t } = useTranslation();
const navigate = useNavigate();
// Lock body scroll while full-screen player is mounted
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const seek = usePlayerStore(s => s.seek);
const repeatMode = usePlayerStore(s => s.repeatMode);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const duration = currentTrack?.duration ?? 0;
// Cover art
const coverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
[currentTrack?.coverArt]
);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Dynamic background color extracted from cover art
const accentColor = useAlbumAccentColor(resolvedCover);
// Star / favorite
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
// Scrubber touch/mouse drag
const scrubberRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const seekFromX = useCallback((clientX: number) => {
const el = scrubberRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
seek(pct);
}, [seek]);
const onScrubStart = useCallback((clientX: number) => {
isDragging.current = true;
seekFromX(clientX);
}, [seekFromX]);
useEffect(() => {
const onMove = (e: TouchEvent | MouseEvent) => {
if (!isDragging.current) return;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
seekFromX(clientX);
};
const onEnd = () => { isDragging.current = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: true });
window.addEventListener('touchend', onEnd);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
};
}, [seekFromX]);
// Drawers
const [showQueue, setShowQueue] = useState(false);
const [showLyrics, setShowLyrics] = useState(false);
// ── Empty state ──
if (!currentTrack) {
return (
<div className="mp-view">
<div className="mp-header">
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
<ChevronDown size={28} />
</button>
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
<div style={{ width: 44 }} />
</div>
<div className="mp-empty">
<Music size={56} style={{ opacity: 0.25 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
</div>
);
}
const bgStyle: CSSProperties = {
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
};
return (
<div className="mp-view" style={bgStyle}>
{/* Header */}
<div className="mp-header">
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
<ChevronDown size={28} />
</button>
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
<div style={{ width: 44 }} />
</div>
{/* Cover Art */}
<div className="mp-cover-wrap">
{resolvedCover ? (
<img src={resolvedCover} alt="" className="mp-cover" />
) : (
<div className="mp-cover mp-cover-fallback">
<Music size={64} />
</div>
)}
</div>
{/* Track Metadata */}
<div className="mp-meta">
<div className="mp-meta-text">
<div className="mp-title truncate">{currentTrack.title}</div>
<div
className="mp-artist truncate"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
>
{currentTrack.artist}
</div>
{(() => {
const parts = [
currentTrack.year,
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
].filter(Boolean);
return parts.length > 0
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
: null;
})()}
</div>
<button
className={`mp-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
{/* Scrubber */}
<div className="mp-scrubber-wrap">
<div
className="mp-scrubber"
ref={scrubberRef}
onMouseDown={e => onScrubStart(e.clientX)}
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
>
<div className="mp-scrubber-bg" />
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
</div>
<div className="mp-scrubber-times">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
</div>
{/* Transport Controls */}
<div className="mp-controls">
<button
className="mp-ctrl-btn mp-ctrl-sm"
onClick={() => shuffleQueue()}
aria-label={t('queue.shuffle')}
>
<Shuffle size={20} />
</button>
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
<button
className={`mp-ctrl-btn mp-ctrl-sm`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
</button>
</div>
{/* Utility Footer */}
<div className="mp-footer">
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
<MicVocal size={20} />
<span>{t('player.lyrics')}</span>
</button>
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
<ListMusic size={20} />
<span>{t('queue.title')}</span>
</button>
</div>
{/* Queue Drawer */}
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
{/* Lyrics Drawer */}
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
}
function saveRecent(q: string, prev: string[]): string[] {
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
}
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
}
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const doSearch = useCallback(
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
try { setResults(await search(q)); }
finally { setLoading(false); }
}, 300),
[musicLibraryFilterVersion]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goCategory = (path: string) => { navigate(path); onClose(); };
const playSong = (song: SearchResults['songs'][number]) => {
commit(query);
playTrack(songToTrack(song));
onClose();
};
const useRecent = (term: string) => {
setQuery(term);
inputRef.current?.focus();
};
const removeRecent = (term: string, e: React.MouseEvent) => {
e.stopPropagation();
setRecentSearches(prev => {
const updated = prev.filter(s => s !== term);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
};
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const showEmpty = !query;
return createPortal(
<div className="mobile-search-overlay">
{/* ── Search bar ── */}
<div className="mobile-search-bar">
<div className="mobile-search-field">
{loading ? (
<div className="mobile-search-spinner" />
) : (
<Search size={16} className="mobile-search-icon" />
)}
<input
ref={inputRef}
className="mobile-search-input"
type="search"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{query && (
<button
className="mobile-search-clear"
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
aria-label={t('search.clearLabel')}
>
<X size={15} />
</button>
)}
</div>
<button className="mobile-search-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
</div>
<div className="mobile-search-results">
{/* ── Empty state ── */}
{showEmpty && (
<div className="mobile-search-empty-state">
{recentSearches.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
<button
className="mobile-search-recent-remove"
onClick={e => removeRecent(term, e)}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
</button>
))}
</div>
)}
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.browse')}</div>
<div className="mobile-search-chips">
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
<Music2 size={15} /> {t('search.albums')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
<Users size={15} /> {t('search.artists')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
<Music size={15} /> {t('search.genres')}
</button>
</div>
</div>
<div className="mobile-search-hint">
<Search size={52} className="mobile-search-hint-icon" />
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
</div>
</div>
)}
{/* ── No results ── */}
{!loading && query && !hasResults && (
<div className="mobile-search-noresults">
{t('search.noResults', { query })}
</div>
)}
{/* ── Results ── */}
{hasResults && (
<>
{results!.artists.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.artists')}</div>
{results!.artists.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{t('search.artists')}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.albums.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.albums')}</div>
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{a.artist}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.songs.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
{s.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
) : (
<div className="mobile-search-avatar">
<Music size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{s.title}</span>
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>,
document.body
);
}
+71 -26
View File
@@ -2,14 +2,15 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import StarRating from './StarRating';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
@@ -31,15 +32,18 @@ export default function PlayerBar() {
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -57,6 +61,14 @@ export default function PlayerBar() {
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art.
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
const radioCoverSrc = useMemo(
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
[currentRadio?.coverArt, currentRadio?.id]
);
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
@@ -80,11 +92,24 @@ export default function PlayerBar() {
{/* Track Info */}
<div className="player-track-info">
<div
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
onClick={() => currentTrack && toggleFullscreen()}
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
{isRadio ? (
currentRadio?.coverArt ? (
<CachedImage
className="player-album-art"
src={radioCoverSrc}
cacheKey={radioCoverKey}
alt={currentRadio.name}
/>
) : (
<div className="player-album-art-placeholder">
<Cast size={20} />
</div>
)
) : currentTrack?.coverArt ? (
<CachedImage
className="player-album-art"
src={coverSrc}
@@ -96,7 +121,7 @@ export default function PlayerBar() {
<Music size={22} />
</div>
)}
{currentTrack && (
{currentTrack && !isRadio && (
<div className="player-art-expand-hint" aria-hidden="true">
<Maximize2 size={16} />
</div>
@@ -104,30 +129,38 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
{currentTrack && !isRadio && (
<StarRating
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
className="player-track-rating"
ariaLabel={t('albumDetail.ratingLabel')}
/>
)}
</div>
{currentTrack && (
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
style={{ flexShrink: 0 }}
>
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
{currentTrack && lastfmSessionKey && (
{currentTrack && !isRadio && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
@@ -145,7 +178,7 @@ export default function PlayerBar() {
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
@@ -156,7 +189,7 @@ export default function PlayerBar() {
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
<button
@@ -170,13 +203,25 @@ export default function PlayerBar() {
</button>
</div>
{/* Waveform Seekbar */}
{/* Waveform Seekbar / Radio live bar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</>
)}
</div>
{/* Lyrics Button */}
+91 -53
View File
@@ -10,6 +10,7 @@ import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import { TFunction } from 'i18next';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -153,13 +154,67 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
);
}
interface QueueHeaderProps {
queue: Track[];
queueIndex: number;
showRemainingTime: boolean;
setShowRemainingTime: React.Dispatch<React.SetStateAction<boolean>>;
activePlaylist: { id: string; name: string } | null;
t: TFunction;
}
function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) {
const currentTime = usePlayerStore((s) => s.currentTime);
if (queue.length === 0) return null;
const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0));
const fmt = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
return (
<div className="queue-header">
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
<span
onClick={() => setShowRemainingTime((v: boolean) => !v)}
data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")}
style={{
fontSize: "13px",
color: "var(--accent)",
whiteSpace: "nowrap",
cursor: "pointer",
userSelect: "none",
}}
>
{queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur}
</span>
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
<ListMusic size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{activePlaylist.name}</span>
</div>
)}
</div>
</div>
);
}
export default function QueuePanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentCoverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt]
@@ -216,7 +271,10 @@ export default function QueuePanel() {
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
@@ -240,6 +298,9 @@ export default function QueuePanel() {
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
// Radio streams are not tracks — reject silently
if (parsedData.type === 'radio') return;
const dropTarget = externalDropTargetRef.current;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
@@ -269,6 +330,15 @@ export default function QueuePanel() {
return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]);
useEffect(function queueAutoScroll() {
if (!queueListRef.current || queueIndex < 0) return;
if (activeTab !== 'queue') return;
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
const nextSong = songs[queueIndex + 1];
if (!nextSong) return;
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
}, [currentTrack, activeTab]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [saveModalOpen, setSaveModalOpen] = useState(false);
@@ -304,9 +374,9 @@ export default function QueuePanel() {
return (
<aside
ref={asideRef}
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
@@ -327,47 +397,17 @@ export default function QueuePanel() {
}
}}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
borderLeftWidth: isQueueVisible ? 1 : 0,
}}
>
<div className="queue-header">
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const remainingSecs = Math.max(0,
(queue[queueIndex]?.duration ?? 0) - currentTime
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
);
const fmt = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
return (
<span
onClick={() => setShowRemainingTime(v => !v)}
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</span>
);
})()}
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ListMusic size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{activePlaylist.name}</span>
</div>
)}
</div>
</div>
<QueueHeader
queue={queue}
queueIndex={queueIndex}
showRemainingTime={showRemainingTime}
setShowRemainingTime={setShowRemainingTime}
activePlaylist={activePlaylist}
t={t}
/>
{currentTrack && (
<div className="queue-current-track">
@@ -398,19 +438,17 @@ export default function QueuePanel() {
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
</div>
</div>
</div>
@@ -469,22 +507,22 @@ export default function QueuePanel() {
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(Number(e.target.value));
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>1s</span><span>10s</span>
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
+147 -8
View File
@@ -1,4 +1,5 @@
import React from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
@@ -7,7 +8,8 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -22,9 +24,11 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
@@ -43,8 +47,70 @@ export default function Sidebar({
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const libraryTriggerPlain = filterId === 'all';
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setDropdownRect({
top: r.bottom + 4,
left: r.left,
width: r.width,
});
}, []);
useLayoutEffect(() => {
if (!libraryDropdownOpen) return;
updateDropdownPosition();
const onWin = () => updateDropdownPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [libraryDropdownOpen, updateDropdownPosition]);
useEffect(() => {
if (!libraryDropdownOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (libraryTriggerRef.current?.contains(t)) return;
const panel = document.querySelector('.nav-library-dropdown-panel');
if (panel?.contains(t)) return;
setLibraryDropdownOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLibraryDropdownOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [libraryDropdownOpen]);
const pickLibrary = (id: 'all' | string) => {
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -72,8 +138,81 @@ export default function Sidebar({
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Hauptnavigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
<nav className="sidebar-nav" aria-label="Main navigation">
{!isCollapsed && (showLibraryPicker ? (
<>
<button
ref={libraryTriggerRef}
type="button"
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
onClick={() => {
setLibraryDropdownOpen(o => !o);
}}
aria-label={t('sidebar.libraryScope')}
aria-expanded={libraryDropdownOpen}
aria-haspopup="listbox"
data-tooltip={libraryDropdownOpen ? undefined : t('sidebar.libraryScope')}
data-tooltip-pos="bottom"
>
{!libraryTriggerPlain ? (
<Music2 size={16} className="nav-library-scope-icon" strokeWidth={2} aria-hidden />
) : null}
<div className="nav-library-scope-text">
<span className="nav-library-scope-title">{t('sidebar.library')}</span>
{selectedFolderName ? (
<span className="nav-library-scope-subtitle" data-tooltip={selectedFolderName} data-tooltip-pos="right">
{selectedFolderName}
</span>
) : null}
</div>
<ChevronDown size={16} strokeWidth={2.25} className="nav-library-scope-chevron" aria-hidden />
</button>
{libraryDropdownOpen &&
createPortal(
<div
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
role="listbox"
aria-label={t('sidebar.libraryScope')}
style={{
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
minWidth: dropdownRect.width,
maxWidth: dropdownRect.width,
boxSizing: 'border-box',
}}
>
<button
type="button"
role="option"
aria-selected={filterId === 'all'}
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary('all')}
>
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
{musicFolders.map(f => (
<button
key={f.id}
type="button"
role="option"
aria-selected={filterId === f.id}
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary(f.id)}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
))}
</div>,
document.body
)}
</>
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
<NavLink
key={item.to}
@@ -116,7 +255,7 @@ export default function Sidebar({
)}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystem.map(item => (
{visibleSystem.map(item => (
<NavLink
key={item.to}
to={item.to}
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export default function StarRating({
value,
onChange,
disabled = false,
maxStars = 5,
maxSelectable: maxSelectableProp,
labelKey = 'albumDetail.ratingLabel',
ariaLabel,
className = '',
}: {
value: number;
onChange: (rating: number) => void;
disabled?: boolean;
/** Number of star buttons (1…maxStars). Default 5. */
maxStars?: number;
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
maxSelectable?: number;
labelKey?: string;
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
ariaLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
const stars = React.useMemo(
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
[maxStars]
);
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
const [hover, setHover] = React.useState(0);
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
const cappedValue = Math.min(Math.max(0, value), selectCap);
React.useEffect(() => {
if (value > 0) setSuppressHoverPreview(false);
}, [value]);
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
const handleStarClick = (n: number) => {
if (disabled || n > selectCap) return;
setSuppressHoverPreview(false);
const next = cappedValue === n ? 0 : n;
onChange(next);
setHover(0);
setPulseStar(null);
setClearShrinkStar(null);
if (next === 0) {
setSuppressHoverPreview(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setClearShrinkStar(n));
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => setPulseStar(n));
});
}
};
const handleContainerLeave = () => {
setHover(0);
setSuppressHoverPreview(false);
};
return (
<div
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
role="radiogroup"
aria-label={ariaLabel ?? t(labelKey)}
aria-disabled={disabled}
onMouseLeave={disabled ? undefined : handleContainerLeave}
>
{stars.map(n => {
const locked = n > selectCap;
return (
<button
key={n}
type="button"
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
onMouseEnter={() =>
!disabled && !suppressHoverPreview && !locked && setHover(n)
}
onClick={() => handleStarClick(n)}
onAnimationEnd={e => {
if (e.currentTarget !== e.target) return;
const name = e.animationName;
if (name === 'star-rating-star-pulse') {
setPulseStar(s => (s === n ? null : s));
}
if (name === 'star-rating-star-clear-shrink') {
setClearShrinkStar(s => (s === n ? null : s));
}
}}
disabled={disabled || locked}
aria-label={`${n}`}
role="radio"
aria-checked={filled(n)}
>
</button>
);
})}
</div>
);
}
+11 -29
View File
@@ -42,9 +42,14 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
group: 'Open Source Classics',
themes: [
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
{ id: 'carbonfox', label: 'Carbonfox', bg: '#161616', card: '#1c1c1c', accent: '#be95ff' },
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'dracula', label: 'Dracula', bg: '#282a36', card: '#44475a', accent: '#bd93f9' },
{ id: 'dawnfox', label: 'Dawnfox', bg: '#faf4ed', card: '#ebe0df', accent: '#907aa9' },
{ id: 'dayfox', label: 'Dayfox', bg: '#f6f2ee', card: '#dbd1dd', accent: '#2848a9' },
{ id: 'duskfox', label: 'Duskfox', bg: '#232136', card: '#2d2a45', accent: '#c4a7e7' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
@@ -53,8 +58,11 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'nightfox', label: 'Nightfox', bg: '#192330', card: '#212e3f', accent: '#719cd6' },
{ id: 'nordfox', label: 'Nordfox', bg: '#2e3440', card: '#39404f', accent: '#81a1c1' },
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
{ id: 'terafox', label: 'Terafox', bg: '#152528', card: '#1d3337', accent: '#a1cdd8' },
],
},
{
@@ -164,29 +172,10 @@ export default function ThemePicker({ value, onChange }: Props) {
return (
<button
key={t.id}
className="theme-card-btn"
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
@@ -208,14 +197,7 @@ export default function ThemePicker({ value, onChange }: Props) {
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{t.label}
</span>
</button>

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