mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -34,6 +34,7 @@ import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock';
|
||||
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
|
||||
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
|
||||
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
|
||||
import { PlayerPlaybackRate } from './playerBar/PlayerPlaybackRate';
|
||||
import { PlayerVolume } from './playerBar/PlayerVolume';
|
||||
import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu';
|
||||
import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar';
|
||||
@@ -279,6 +280,10 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isLayoutVisible('playbackRate') && (
|
||||
<PlayerPlaybackRate t={t} />
|
||||
)}
|
||||
|
||||
{isLayoutVisible('equalizer') && (
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PictureInPicture2, SlidersVertical } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { PlayerVolume } from './PlayerVolume';
|
||||
import { PlayerPlaybackRateMenuSection } from './PlayerPlaybackRate';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
@@ -38,6 +39,7 @@ export function PlayerOverflowMenu({
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
const showEqualizer = isLayoutVisible('equalizer');
|
||||
const showMiniPlayer = isLayoutVisible('miniPlayer');
|
||||
const showPlaybackRate = isLayoutVisible('playbackRate');
|
||||
return createPortal(
|
||||
<div
|
||||
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
|
||||
@@ -73,6 +75,9 @@ export function PlayerOverflowMenu({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{utilityMenuMode === 'full' && showPlaybackRate && (
|
||||
<PlayerPlaybackRateMenuSection t={t} />
|
||||
)}
|
||||
<PlayerVolume
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user