Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ Rust/Tauri 2. Entry point: `src-tauri/src/main.rs` → `lib.rs`.
- **Smart crossfade (album-aware)**: A `SharedPlayback::smart_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.smart_crossfade']`) suppresses the fade between two tracks of the same album so concept records / live sets aren't smeared by the equal-power mix. Implementation is one atomic + one DB round trip in the analytics `PrefetchNext` handler: it looks up `current.album_id` + `next.album_id`, writes the bool to `SharedPlayback::pending_next_same_album` right before dispatching `SetNextTrack`. The decoder checks both atomics at mix-decision time; when smart-skip applies, it bypasses the mix branch and falls through to the existing gapless EOF swap. The hint is naturally one-shot — each new prefetch overwrites it, and manual `LoadAndPlay` paths never consult it.
- **Dynamic crossfade (tempo-aware)**: A `SharedPlayback::dynamic_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.dynamic_crossfade']`) scales each fade by the BPM gap between two tracks. Analytics `PrefetchNext` reads `track_analysis.bpm` for current + next; when both are known, it picks a factor (≤8 BPM → 100%, ≤20 → 75%, ≤40 → 50%, else 30%), applies a 1500 ms floor (clamped against the base) and writes the result to `SharedPlayback::pending_next_crossfade_ms` right before `SetNextTrack`. The decoder reads the override as the effective `cf_ms` when non-zero and clears it the instant the mix actually starts. Either BPM unknown → no override, decoder falls back to the static `crossfade_ms`. Smart crossfade still wins for same-album hand-offs.
- **Spectrum visualizer (FFT)**: [`audio/spectrum.rs`](src-tauri/src/audio/spectrum.rs) lives on the decoder thread, fed with post-EQ samples right before they hit the SPSC ring (cpal callback is never touched). 2048-pt windowed real FFT via `realfft`, 50% overlap, 48 log-spaced bands from 30 Hz to 16 kHz, throttled to ~30 Hz. Gated behind `SharedPlayback::visualizer_enabled` (atomic `bool`) so when the toggle is off, `SpectrumAnalyzer::feed` short-circuits at the first atomic load — zero allocations, zero FFT cost. Frames are emitted as `player:spectrum` Tauri events (`Vec<f32>` in 0..1, peaks may briefly overshoot). Frontend [`SpectrumVisualizer`](src/components/player/SpectrumVisualizer.tsx) is a Canvas with `requestAnimationFrame` decay (jump up fast, fall slow) so the visual feels fluid even though the source cadence is sub-screen. Persisted in `profile_setting['ui.visualizer']`, default OFF; mounted inside [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx) above the progress bar.
- **Playback speed (0.5×–2×)**: resampler-shift — the decoder feeds rubato a fake source rate of `actual_rate * speed` so each cpal output sample represents `speed` source samples of audio. Pitch follows speed (no time-stretching). State on `SharedPlayback`: `playback_speed_bits` (`AtomicU32` f32 bits, clamped) + `speed_dirty` (one-shot flag). The decoder polls dirty once per `'pkt` loop and calls `ActiveStream::rebuild_resampler` on the primary + crossfade prefetched secondary, then clears the local already-resampled buffers and runs `drain_silent` so the audible transition is < 20 ms. `set_playback_speed` rebases `samples_played`/`base_offset_ms` against the position **at the old speed** before flipping the atomic, so the progress bar stays continuous across the change. Both `current_position_ms` and `session_listened_ms` multiply by speed, so analytics credit fires on track-time covered, not wall-clock — listening to a 6 min track at 2× counts as 6 min for the heatmap. Persisted in `profile_setting['audio.playback_speed']`. UI is a compact text pill [`SpeedControl`](src/components/player/SpeedControl.tsx) next to the volume — same footprint as the `80%` label.
- **Player-bar overflow menu**: [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) is the "⋯" popover that absorbs Fullscreen + Mini-player so the right side of the bar stops growing every time we ship a feature. Primary slot stays: Lyrics, Queue, Device picker, Speed pill, Volume. Opt-in (visibility-toggled): A-B loop, Sleep timer. When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage / feedback warrants it.
- **Playback speed (0.5×–2×)**: resampler-shift — the decoder feeds rubato a fake source rate of `actual_rate * speed` so each cpal output sample represents `speed` source samples of audio. Pitch follows speed (no time-stretching). State on `SharedPlayback`: `playback_speed_bits` (`AtomicU32` f32 bits, clamped) + `speed_dirty` (one-shot flag). The decoder polls dirty once per `'pkt` loop and calls `ActiveStream::rebuild_resampler` on the primary + crossfade prefetched secondary, then clears the local already-resampled buffers and runs `drain_silent` so the audible transition is < 20 ms. `set_playback_speed` rebases `samples_played`/`base_offset_ms` against the position **at the old speed** before flipping the atomic, so the progress bar stays continuous across the change. Both `current_position_ms` and `session_listened_ms` multiply by speed, so analytics credit fires on track-time covered, not wall-clock — listening to a 6 min track at 2× counts as 6 min for the heatmap. Persisted in `profile_setting['audio.playback_speed']`. UI lives inside the player-bar overflow ("⋯") menu (range slider + 5 presets); the "⋯" trigger surfaces a `1.25×` emerald badge when speed ≠ 1× so the user keeps live feedback without opening the menu.
- **Player-bar overflow menu**: [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) is the "⋯" popover that hosts every secondary player action so the right side of the bar stops growing every time we ship a feature. Default contents: playback speed (range slider + 5 presets), A-B loop (a labelled row wrapping the standalone [`AbLoopButton`](src/components/player/AbLoopButton.tsx)) and the full sleep-timer panel (presets grid + end-of-track + custom-minutes form — duplicated inline rather than mounting a nested popover, since a popover-in-popover breaks outside-click detection). Primary slot: Lyrics, Queue, Device picker, "⋯", Volume + the Spotify-style right cluster (Mini-player, Fullscreen). Users can **pin** Sleep timer / A-B loop to the bar from Settings (`profile_setting['ui.show_sleep_timer' | 'ui.show_ab_loop']`, default OFF) — when pinned, the entry is omitted from the overflow menu so it isn't duplicated. The "⋯" trigger surfaces a single badge that prioritises the sleep-timer countdown when armed, otherwise the current playback speed if ≠ 1× — so the user keeps live feedback without opening the menu. The trigger auto-hides only when nothing inside would render (Spotify mode + both pinnable entries pinned). When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage / feedback warrants it, and add a pin toggle if both modes make sense.
- **Auto-backup**: [`backup.rs`](src-tauri/src/backup.rs) is a single tokio task spawned at boot that reads `app_setting['backup.enabled' | 'backup.interval_days' | 'backup.folder' | 'backup.retention' | 'backup.last_run_at']` and either parks on a `tokio::sync::Notify` (disabled) or sleeps until `last_run_at + interval_days * 86_400_000`. On wakeup, [`run_one_backup`](src-tauri/src/backup.rs) iterates every row in `profile`, calls the shared `profile_io::write_archive` (pub-crate-ified to keep auto + manual exports bit-compatible) into `<sanitized-name>-<UTC-timestamp>.waveflow`, then prunes per-profile archives beyond `retention` by mtime. Per-profile errors are logged but don't abort the pass. `set_backup_config` signals the same `Notify` so the loop reconfigures within seconds, not at the next deadline. Default folder is `<app_data>/waveflow/backups/`. UI: [`BackupCard`](src/components/views/settings/BackupCard.tsx) in Settings → Stockage right after the manual export/import block.
- **Stats JSON export**: [`commands/stats.rs::export_stats_json(range, target_path)`](src-tauri/src/commands/stats.rs) bundles overview + top 100 tracks / artists / albums + listening-by-day + listening-by-hour into a versioned (`schema_version: 1`) pretty-printed JSON file. Writes the file from Rust via `spawn_blocking` so we don't depend on `tauri-plugin-fs` just to round-trip a string between frontend and disk. UI: Download button next to the range selector in StatisticsView.
- **WaveFlow Wrapped (year-in-review)**: [`commands/wrapped.rs::get_wrapped(year)`](src-tauri/src/commands/wrapped.rs) bundles every aggregate the Spotify-Wrapped clone needs into a single payload — overview (plays / minutes / unique tracks / artists / albums), top 10 tracks + artists + top 5 albums (sharing the existing stats row shapes so the frontend's artwork resolver works unchanged), per-month + per-hour histograms, most active day, mood profile (listening-weighted average BPM + LUFS, with a 5-tier energy label derived from BPM buckets), first listen of the year, and longest consecutive-day listening streak. Year bounds are computed in **local time** (Jan 1 00:00 → Dec 31 23:59:59, exclusive upper) so a play at 11:59 PM on Dec 31 lands in the right year regardless of UTC offset. `available_wrapped_years` enumerates every year that has at least one `play_event` row, sorted DESC, so the year picker doesn't list empty years. Frontend: [`WrappedView`](src/components/views/WrappedView.tsx) is a story-style overlay (`fixed inset-0 z-100`, similar pattern to `FullscreenNowPlaying`) with 10–12 auto-advancing slides (intro → minutes → top tracks → top artists → top albums → most active day → mood → listening clock → streak → first listen → months → outro). Slides without data are filtered out of the rotation (no analysed track → mood slide skipped; no streak ≥ 2 days → streak slide skipped) so the experience tightens to what the profile actually has. Top-of-screen progress segments + space-to-pause + arrow-key navigation match Instagram / Snapchat story conventions. Entry point: a banner on [`HomeView`](src/components/views/HomeView.tsx) that only renders when `available_wrapped_years` returns ≥ 1 year, gradient-styled to match the overlay's accent so the transition feels continuous. **Shareable PNG**: the overlay's top-bar Share button opens a Save / Copy menu. [`lib/wrappedCard.ts`](src/lib/wrappedCard.ts) builds a 1080×1920 portrait PNG on an offscreen Canvas (radial-gradient backdrop sampled from the year accent, big year + minutes number, top 5 tracks with cover thumbnails, mood + streak strip), then the Save path ships the bytes to [`save_share_image`](src-tauri/src/commands/share.rs) which writes via `spawn_blocking`, while Copy uses `navigator.clipboard.write` with `ClipboardItem("image/png")`. No font file in the bundle — text uses the WebView's native stack.
Expand Down
4 changes: 2 additions & 2 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ Both `current_position_ms()` and `session_listened_ms()` multiply the wall-clock

### UI

[`SpeedControl`](../../src/components/player/SpeedControl.tsx) is a compact text pill (`1.0×` / `1.25×`) next to the volume slider — same footprint as the volume's percentage label. Click opens a popover with a range slider (step 0.05) and five preset buttons (0.75 / 1 / 1.25 / 1.5 / 2). Emerald accent when speed ≠ 1×. Hidden in Spotify mode (the Web Playback SDK has no speed control). No new icon — the text is the affordance.
Speed lives inside the player-bar overflow ("⋯") menu — range slider (step 0.05) + five preset buttons (0.75 / 1 / 1.25 / 1.5 / 2) — rather than a dedicated pill, since most users never touch it. When speed ≠ 1×, the "⋯" trigger surfaces a compact `1.25×` badge in emerald so the user keeps a live indicator without opening the menu. Hidden entirely in Spotify mode (the Web Playback SDK has no speed control).

## A-B repeat

Musicolet-style intra-track loop. Two `AtomicU64` endpoints on `SharedPlayback` (`loop_a_ms`, `loop_b_ms`) — when both are set and `b > a`, the decoder loop in [`audio/decoder.rs::play_track`](../../src-tauri/src/audio/decoder.rs) checks the playhead once per packet and seeks back to A whenever it crosses B. Skipped during a crossfade because the loop is a single-track concern (looping mid-fade would fight the cross-track mix). Auto-cleared on every `LoadAndPlay` so the new track doesn't inherit stale endpoints from the previous one.

Three commands cover the lifecycle: `player_set_ab_loop` (set one or both endpoints), `player_clear_ab_loop`, `player_get_ab_loop`. Each one emits `player:ab-loop` so the UI button + ProgressBar markers stay in sync across views without polling.

UI is a tri-state click cycle in [`AbLoopButton`](../../src/components/player/AbLoopButton.tsx) — idle → A captured (amber) → A+B armed (emerald) → clear — with an "A" / "AB" badge over the icon. The PlayerBar's [`ProgressBar`](../../src/components/player/ProgressBar.tsx) renders the endpoints as coloured pin markers (amber A, rose B) with a tinted region between them so the loop is legible at a glance. Hidden by default — enable from Settings → Lecture → "Afficher la boucle A-B" (`profile_setting['ui.show_ab_loop']`).
UI is a tri-state click cycle in [`AbLoopButton`](../../src/components/player/AbLoopButton.tsx) — idle → A captured (amber) → A+B armed (emerald) → clear — with an "A" / "AB" badge over the icon. The PlayerBar's [`ProgressBar`](../../src/components/player/ProgressBar.tsx) renders the endpoints as coloured pin markers (amber A, rose B) with a tinted region between them so the loop is legible at a glance. By default the button lives in the player-bar overflow ("⋯") menu wrapped as a labelled row; pinning it to a primary slot is a one-click toggle in Settings → Lecture (`profile_setting['ui.show_ab_loop']`).

## Queue

Expand Down
28 changes: 14 additions & 14 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,26 +104,26 @@ Track tables themselves are **borderless** — no `rounded-2xl border bg-white`

Right side of [`PlayerBar`](../../src/components/player/PlayerBar.tsx) is the highest-pressure real estate in the UI — every new feature wants an icon there. To keep the bar from running out of width on narrow windows, controls cluster by frequency:

| Tier | Controls | Where |
| ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
| **Primary** | Lyrics, Queue, Device picker, Speed pill, Volume | Always visible |
| **Overflow** | Fullscreen, Mini-player | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover |
| **Opt-in** | A-B loop, Sleep timer | Visibility-toggle pattern (see below) |
| Tier | Controls | Where |
| ------------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Primary** | Lyrics, Queue, Device picker, "⋯", Volume, **Mini-player**, **Fullscreen** | Always visible. Spotify-style right cluster (mini-player + fullscreen) sits after volume |
| **Overflow** | Playback speed (slider + presets), A-B loop, Sleep timer (panel) | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover; the trigger itself is hidden when nothing inside is left |
| **Pinnable** | A-B loop, Sleep timer (promote to primary) | Toggle in Settings → Lecture (see below) |

When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage data or user feedback warrants it.
When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage data or user feedback warrants it. If both placements make sense, expose a pin toggle. The "⋯" trigger auto-hides when its menu would be empty.

The **Speed pill** ([`SpeedControl`](../../src/components/player/SpeedControl.tsx)) is intentionally text-only (`1.0×` / `1.25×`) instead of an icon — it doubles as the live value display, so the user sees the current speed without opening the popover. Same footprint as the volume's `80%` label. See [playback / Playback speed](playback.md#playback-speed-05--2) for the backend side.
**Playback speed** lives inside [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) (range slider + five presets) rather than a dedicated bar button — it's used too rarely to deserve a permanent slot. When speed ≠ 1×, the "⋯" trigger surfaces a compact `1.25×` badge in emerald (same corner as the sleep-timer countdown — the countdown wins when both are active). See [playback / Playback speed](playback.md#playback-speed-05--2) for the backend side.

### Visibility toggles
### Pin toggles

A handful of niche playback features live behind a per-profile visibility toggle so the player bar stays uncluttered for typical users. Both default to **off** and opt in from Settings → Lecture:
A-B loop and Sleep timer are **always available** — they live in the "⋯" overflow menu by default. The pin toggles let frequent users promote them to a primary slot on the bar so they're one click away. Both default to **off**:

| Setting key | Button | Default |
| --------------------- | ---------------------------- | ------- |
| `ui.show_sleep_timer` | Moon icon (sleep timer menu) | off |
| `ui.show_ab_loop` | Repeat icon (A-B loop) | off |
| Setting key | Pinned button rendered in primary slot | Default |
| --------------------- | -------------------------------------- | ------- |
| `ui.show_sleep_timer` | Moon icon (sleep timer menu) | off |
| `ui.show_ab_loop` | Repeat icon (A-B loop) | off |

The PlayerBar listens to `waveflow:sleep-timer-visibility` / `waveflow:ab-loop-visibility` window events dispatched by the Settings toggle so the icons appear / disappear without a polling loop.
When a pin is OFF, the entry stays in the overflow menu and the sleep-timer countdown badge surfaces on the "⋯" trigger itself so the user keeps live feedback while the timer is armed. The PlayerBar listens to `waveflow:sleep-timer-visibility` / `waveflow:ab-loop-visibility` window events dispatched by the Settings toggle so the layout re-renders without a polling loop.

## Keyboard shortcuts

Expand Down
Loading
Loading