feat(playback): global speed with three strategies (#852)

* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
cucadmuh
2026-05-22 19:59:28 +03:00
committed by GitHub
parent d54eceaf3b
commit e8e41752a7
64 changed files with 2405 additions and 70 deletions
@@ -0,0 +1,81 @@
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import { PlaybackRateControls } from '../settings/audio/PlaybackRateBlock';
import { usePlaybackRateStore } from '../../store/playbackRateStore';
import { useOrbitStore } from '../../store/orbitStore';
import {
PLAYBACK_SPEED_STEP,
clampPlaybackSpeed,
formatSpeedLabel,
isPlaybackRateApplied,
} from '../../utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '../../utils/orbit';
import { usePlayerBarAnchoredPopover } from '../../hooks/usePlayerBarAnchoredPopover';
const POPOVER_WIDTH = 320;
interface Props {
t: TFunction;
}
export function PlayerPlaybackRateMenuSection({ t }: Props) {
const enabled = usePlaybackRateStore(s => s.enabled);
if (!enabled) return null;
return (
<div className="player-playback-rate-menu-section">
<PlaybackRateControls t={t} showEnable={false} />
</div>
);
}
export function PlayerPlaybackRate({ t }: Props) {
const enabled = usePlaybackRateStore(s => s.enabled);
const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const setSpeed = usePlaybackRateStore(s => s.setSpeed);
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const { open, setOpen, popStyle, btnRef, popRef } = usePlayerBarAnchoredPopover(POPOVER_WIDTH);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const handleWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!enabled) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -PLAYBACK_SPEED_STEP : PLAYBACK_SPEED_STEP;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [enabled, speed, setSpeed]);
if (!enabled) return null;
return (
<>
<button
ref={btnRef}
type="button"
className={`player-btn player-btn-sm player-playback-rate-btn${open ? ' active' : ''}${effectActive ? ' player-playback-rate-btn--live' : ''}`}
onClick={() => setOpen(v => !v)}
onWheel={handleWheel}
aria-label={t('player.playbackRate')}
aria-expanded={open}
data-tooltip={t('player.playbackRate')}
>
{formatSpeedLabel(speed)}
</button>
{open && createPortal(
<div
ref={popRef}
className="player-playback-rate-popover"
style={popStyle}
>
<PlaybackRateControls t={t} showEnable={false} />
</div>,
document.body,
)}
</>
);
}