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
+12
View File
@@ -8,7 +8,9 @@ import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/lou
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
import { NormalizationBlock } from './audio/NormalizationBlock';
import { Gauge } from 'lucide-react';
import { PlaybackBehaviorBlock } from './audio/PlaybackBehaviorBlock';
import { PlaybackRateBlock } from './audio/PlaybackRateBlock';
import { TrackPreviewsSection } from './audio/TrackPreviewsSection';
export function AudioTab() {
@@ -77,6 +79,16 @@ export function AudioTab() {
</div>
</SettingsSubSection>
{/* Playback speed */}
<SettingsSubSection
title={t('settings.playbackRateTitle')}
icon={<Gauge size={16} />}
>
<div className="settings-card">
<PlaybackRateBlock t={t} />
</div>
</SettingsSubSection>
{/* Replay Gain + Crossfade + Gapless */}
<SettingsSubSection
title={t('settings.playbackTitle')}
@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
import { Gauge, Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
import LastfmIcon from '../LastfmIcon';
import {
usePlayerBarLayoutStore,
@@ -11,6 +11,7 @@ const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
starRating: 'settings.playerBarStarRating',
favorite: 'settings.playerBarFavorite',
lastfmLove: 'settings.playerBarLastfmLove',
playbackRate: 'settings.playerBarPlaybackRate',
equalizer: 'settings.playerBarEqualizer',
miniPlayer: 'settings.playerBarMiniPlayer',
};
@@ -23,6 +24,7 @@ const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> =
<LastfmIcon size={16} />
</span>
),
playbackRate: <Gauge size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
equalizer: <SlidersVertical size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
};
@@ -0,0 +1,211 @@
import React, { useCallback } from 'react';
import type { TFunction } from 'i18next';
import {
PLAYBACK_PITCH_MAX,
PLAYBACK_PITCH_MIN,
PLAYBACK_PITCH_STEP,
PLAYBACK_SPEED_MAX,
PLAYBACK_SPEED_MIN,
PLAYBACK_SPEED_PRESETS,
PLAYBACK_SPEED_STEP,
PLAYBACK_STRATEGIES,
clampPlaybackPitch,
clampPlaybackSpeed,
derivedVarispeedSemitones,
formatPitchLabel,
formatSpeedLabel,
isPlaybackRateApplied,
type PlaybackStrategy,
} from '../../../utils/audio/playbackRateHelpers';
import { usePlaybackRateStore } from '../../../store/playbackRateStore';
import { useOrbitStore } from '../../../store/orbitStore';
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
interface Props {
t: TFunction;
/** When false, hide master enable (player popup). */
showEnable?: boolean;
}
export function PlaybackRateControls({ t, showEnable = true }: Props) {
const compact = !showEnable;
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 {
setEnabled,
setStrategy,
setSpeed,
setPitchSemitones,
applyPresetSpeed,
} = usePlaybackRateStore();
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const derivedPitch = derivedVarispeedSemitones(speed);
const strategyLabel = (s: PlaybackStrategy) => {
switch (s) {
case 'speed_corrected':
return t('settings.playbackRateStrategySpeed');
case 'varispeed':
return t('settings.playbackRateStrategyVarispeed');
case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserve');
}
};
const handleWheelSpeed = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled) return;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -PLAYBACK_SPEED_STEP : PLAYBACK_SPEED_STEP;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [compact, enabled, speed, setSpeed]);
const handleWheelPitch = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled || strategy !== 'preserve_pitch') return;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -PLAYBACK_PITCH_STEP : PLAYBACK_PITCH_STEP;
setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta));
}, [compact, enabled, strategy, pitchSemitones, setPitchSemitones]);
return (
<div
className={`playback-rate-controls${compact ? ' playback-rate-controls--compact' : ''}`}
onWheel={compact ? handleWheelSpeed : undefined}
>
{showEnable && (
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateEnabledDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.playbackRateEnabled')}>
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
)}
{(!showEnable || enabled) && (
<>
{showEnable && <div className="divider" />}
<div className="playback-rate-strategy-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateStrategy')}</span>
)}
<div className="playback-rate-strategy-btns">
{PLAYBACK_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setStrategy(s)}
>
{strategyLabel(s)}
</button>
))}
</div>
</div>
<div className="playback-rate-slider-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateSpeed')}</span>
)}
<input
type="range"
min={PLAYBACK_SPEED_MIN}
max={PLAYBACK_SPEED_MAX}
step={PLAYBACK_SPEED_STEP}
value={speed}
onChange={e => setSpeed(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRateSpeed')}
/>
<span className="playback-rate-value">{formatSpeedLabel(speed)}</span>
</div>
<div className="playback-rate-presets">
{PLAYBACK_SPEED_PRESETS.map(preset => (
<button
key={preset}
type="button"
className={`btn btn-sm ${Math.abs(speed - preset) < 0.001 ? 'btn-primary' : 'btn-surface'}`}
onClick={() => applyPresetSpeed(preset)}
>
{formatSpeedLabel(preset)}
</button>
))}
</div>
{strategy === 'varispeed' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedPitch', {
value: formatPitchLabel(derivedPitch),
})}
</div>
)}
{strategy === 'speed_corrected' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateAutoPitch')}
</div>
)}
{strategy === 'preserve_pitch' && (
<div className="playback-rate-slider-row" onWheel={compact ? handleWheelPitch : undefined}>
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRatePitch')}</span>
)}
<input
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={PLAYBACK_PITCH_STEP}
value={pitchSemitones}
onChange={e => setPitchSemitones(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')}
/>
<span className="playback-rate-value">{formatPitchLabel(pitchSemitones)}</span>
</div>
)}
{!compact && (
<p className="playback-rate-hint" style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
{t('settings.playbackRateHint')}
</p>
)}
{orbitActive && enabled && (
<p className="playback-rate-orbit" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t(compact ? 'settings.playbackRateOrbitPausedShort' : 'settings.playbackRateOrbitPaused')}
</p>
)}
{!compact && !effectActive && enabled && !orbitActive && (
<p className="playback-rate-neutral" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('settings.playbackRateNeutral')}
</p>
)}
</>
)}
</div>
);
}
export function PlaybackRateBlock({ t }: { t: TFunction }) {
return <PlaybackRateControls t={t} showEnable />;
}
+1
View File
@@ -35,6 +35,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },