mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -53,9 +53,12 @@ import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore } from '../store/fontStore';
|
||||
import { useEqStore } from '../store/eqStore';
|
||||
import { usePlaybackRateStore } from '../store/playbackRateStore';
|
||||
import { usePlaybackRateOrbitSync } from '../hooks/usePlaybackRateOrbitSync';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import {
|
||||
persistSidebarCollapsed,
|
||||
@@ -78,6 +81,7 @@ export function AppShell() {
|
||||
useOrbitHost();
|
||||
useOrbitGuest();
|
||||
useOrbitBodyAttrs();
|
||||
usePlaybackRateOrbitSync();
|
||||
useTrayMenuI18n();
|
||||
useServerCapabilitiesProbe();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
@@ -124,6 +128,7 @@ export function AppShell() {
|
||||
|
||||
useEffect(() => {
|
||||
useEqStore.getState().syncToRust();
|
||||
usePlaybackRateStore.getState().syncToRust();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -125,6 +125,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)',
|
||||
'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)',
|
||||
'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
|
||||
'Playback speed: global 0.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlaybackRateStore } from '../store/playbackRateStore';
|
||||
|
||||
/** Re-sync playback rate when Orbit enters or leaves shared playback. */
|
||||
export function usePlaybackRateOrbitSync() {
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
|
||||
useEffect(() => {
|
||||
usePlaybackRateStore.getState().syncToRust();
|
||||
}, [role, phase]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Fixed popover anchored above a player-bar trigger (overflow menu / speed btn). */
|
||||
export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePopStyle = () => {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const MARGIN = 8;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const left = Math.min(
|
||||
Math.max(r.right - width, MARGIN),
|
||||
window.innerWidth - width - MARGIN,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width,
|
||||
bottom: window.innerHeight - r.top + MARGIN,
|
||||
zIndex,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onReposition = () => updatePopStyle();
|
||||
window.addEventListener('resize', onReposition);
|
||||
window.addEventListener('scroll', onReposition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
};
|
||||
}, [open, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (btnRef.current?.contains(target) || popRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return { open, setOpen, popStyle, btnRef, popRef };
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function useUtilityOverflowMenu(
|
||||
|
||||
useEffect(() => {
|
||||
if (!utilityMenuOpen) return;
|
||||
const MENU_WIDTH = 238;
|
||||
const MENU_WIDTH = utilityMenuMode === 'volume' ? 238 : 320;
|
||||
const MARGIN = 8;
|
||||
const updateMenuPos = () => {
|
||||
const btn = utilityBtnRef.current;
|
||||
@@ -101,7 +101,7 @@ export function useUtilityOverflowMenu(
|
||||
window.removeEventListener('resize', updateMenuPos);
|
||||
window.removeEventListener('scroll', updateMenuPos, true);
|
||||
};
|
||||
}, [utilityMenuOpen]);
|
||||
}, [utilityMenuOpen, utilityMenuMode]);
|
||||
|
||||
return {
|
||||
utilityOverflow,
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Warteschlange einklappen, Breite ändern',
|
||||
moreOptions: 'Weitere Optionen',
|
||||
equalizer: 'Equalizer',
|
||||
playbackRate: 'Geschwindigkeit',
|
||||
miniPlayer: 'Mini-Player',
|
||||
lyrics: 'Lyrics',
|
||||
fsLyricsToggle: 'Lyrics im Vollbild',
|
||||
|
||||
@@ -326,8 +326,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Sterne-Bewertung',
|
||||
playerBarFavorite: 'Favorit (Herz)',
|
||||
playerBarLastfmLove: 'Last.fm Love',
|
||||
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini-Player',
|
||||
playbackRateTitle: 'Wiedergabegeschwindigkeit',
|
||||
playbackRateEnabled: 'Wiedergabegeschwindigkeit aktivieren',
|
||||
playbackRateEnabledDesc: 'Globale Geschwindigkeit für alle Titel. Gilt nicht für Radio und Vorschau.',
|
||||
playbackRateStrategy: 'Strategie',
|
||||
playbackRateStrategySpeed: 'Geschwindigkeit',
|
||||
playbackRateStrategyVarispeed: 'Tonhöhe folgt dem Tempo',
|
||||
playbackRateStrategyPreserve: 'Tonhöhe',
|
||||
playbackRateSpeed: 'Geschwindigkeit',
|
||||
playbackRatePitch: 'Tonhöhe',
|
||||
playbackRateDerivedPitch: 'Tonhöhenverschiebung durch Geschwindigkeit: {{value}}',
|
||||
playbackRateAutoPitch: 'Die Tonhöhe wird automatisch korrigiert.',
|
||||
playbackRateHint: '„Geschwindigkeit“ behält die Tonhöhe bei. „Tonhöhe folgt dem Tempo“ verschiebt die Tonhöhe mit der Geschwindigkeit. „Tonhöhe“ fügt eine manuelle Verschiebung hinzu. Höhere CPU-Last als bei „Tonhöhe folgt dem Tempo“. Nicht für Radio, Vorschau oder Orbit.',
|
||||
playbackRateNeutral: 'Bei 1,0× und ohne Tonverschiebung ist die Wiedergabe normal.',
|
||||
playbackRateOrbitPaused: 'Während Orbit-Sessions nicht aktiv — Wiedergabe bleibt bei 1,0× für die Synchronisation.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1,0× für Sync.',
|
||||
advancedMode: 'Erweitert',
|
||||
advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.',
|
||||
advancedBadge: 'Erweitert',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Collapse queue, resize',
|
||||
moreOptions: 'More options',
|
||||
equalizer: 'Equalizer',
|
||||
playbackRate: 'Playback speed',
|
||||
miniPlayer: 'Mini Player',
|
||||
lyrics: 'Lyrics',
|
||||
fsLyricsToggle: 'Lyrics in fullscreen',
|
||||
|
||||
@@ -329,8 +329,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Star rating',
|
||||
playerBarFavorite: 'Favorite (heart)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Playback speed',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playbackRateTitle: 'Playback speed',
|
||||
playbackRateEnabled: 'Enable playback speed',
|
||||
playbackRateEnabledDesc: 'Change speed globally for all tracks. Not applied to radio or previews.',
|
||||
playbackRateStrategy: 'Strategy',
|
||||
playbackRateStrategySpeed: 'Speed',
|
||||
playbackRateStrategyVarispeed: 'Varispeed',
|
||||
playbackRateStrategyPreserve: 'Pitch shift',
|
||||
playbackRateSpeed: 'Speed',
|
||||
playbackRatePitch: 'Pitch',
|
||||
playbackRateDerivedPitch: 'Pitch shift from speed: {{value}}',
|
||||
playbackRateAutoPitch: 'Pitch is corrected automatically.',
|
||||
playbackRateHint: 'Speed keeps pitch natural. Varispeed changes pitch with speed. Pitch shift adds a manual offset. Uses more CPU than varispeed. Not for radio, previews, or Orbit.',
|
||||
playbackRateNeutral: 'At 1.0× with no pitch offset, playback is normal.',
|
||||
playbackRateOrbitPaused: 'Not applied during Orbit sessions — playback stays at 1.0× for sync.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1.0× for sync.',
|
||||
advancedMode: 'Advanced',
|
||||
advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.',
|
||||
advancedBadge: 'Advanced',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Contraer cola, cambiar ancho',
|
||||
moreOptions: 'Más opciones',
|
||||
equalizer: 'Ecualizador',
|
||||
playbackRate: 'Velocidad',
|
||||
miniPlayer: 'Mini reproductor',
|
||||
lyrics: 'Letras',
|
||||
fsLyricsToggle: 'Letras en pantalla completa',
|
||||
|
||||
@@ -325,8 +325,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Calificación con estrellas',
|
||||
playerBarFavorite: 'Favorito (corazón)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Velocidad de reproducción',
|
||||
playerBarEqualizer: 'Ecualizador',
|
||||
playerBarMiniPlayer: 'Mini reproductor',
|
||||
playbackRateTitle: 'Velocidad de reproducción',
|
||||
playbackRateEnabled: 'Activar velocidad de reproducción',
|
||||
playbackRateEnabledDesc: 'Velocidad global para todas las pistas. No se aplica a la radio ni a las vistas previas.',
|
||||
playbackRateStrategy: 'Estrategia',
|
||||
playbackRateStrategySpeed: 'Velocidad',
|
||||
playbackRateStrategyVarispeed: 'Con tono',
|
||||
playbackRateStrategyPreserve: 'Tono',
|
||||
playbackRateSpeed: 'Velocidad',
|
||||
playbackRatePitch: 'Tono',
|
||||
playbackRateDerivedPitch: 'Cambio de tono por velocidad: {{value}}',
|
||||
playbackRateAutoPitch: 'El tono se corrige automáticamente.',
|
||||
playbackRateHint: '« Velocidad » mantiene el tono natural. « Con tono » cambia el tono con la velocidad. « Tono » añade un desplazamiento manual. Usa más CPU que « Con tono ». No para radio, vistas previas u Orbit.',
|
||||
playbackRateNeutral: 'A 1,0× y sin desplazamiento de tono, la reproducción es normal.',
|
||||
playbackRateOrbitPaused: 'No se aplica durante sesiones Orbit — la reproducción permanece a 1,0× para sincronizar.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1,0× para sincronizar.',
|
||||
advancedMode: 'Avanzado',
|
||||
advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.',
|
||||
advancedBadge: 'Avanzado',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Réduire la file, redimensionner',
|
||||
moreOptions: 'Plus d’options',
|
||||
equalizer: 'Égaliseur',
|
||||
playbackRate: 'Vitesse',
|
||||
miniPlayer: 'Mini-lecteur',
|
||||
lyrics: 'Paroles',
|
||||
fsLyricsToggle: 'Paroles en plein écran',
|
||||
|
||||
@@ -323,8 +323,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Note par étoiles',
|
||||
playerBarFavorite: 'Favori (cœur)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Vitesse de lecture',
|
||||
playerBarEqualizer: 'Égaliseur',
|
||||
playerBarMiniPlayer: 'Mini-lecteur',
|
||||
playbackRateTitle: 'Vitesse de lecture',
|
||||
playbackRateEnabled: 'Activer la vitesse de lecture',
|
||||
playbackRateEnabledDesc: 'Vitesse globale pour toutes les pistes. Non appliquée à la radio ni aux aperçus.',
|
||||
playbackRateStrategy: 'Stratégie',
|
||||
playbackRateStrategySpeed: 'Vitesse',
|
||||
playbackRateStrategyVarispeed: 'Avec hauteur',
|
||||
playbackRateStrategyPreserve: 'Hauteur',
|
||||
playbackRateSpeed: 'Vitesse',
|
||||
playbackRatePitch: 'Hauteur',
|
||||
playbackRateDerivedPitch: 'Décalage de hauteur lié à la vitesse : {{value}}',
|
||||
playbackRateAutoPitch: 'La hauteur est corrigée automatiquement.',
|
||||
playbackRateHint: '« Vitesse » conserve la hauteur naturelle. « Avec hauteur » change la hauteur avec la vitesse. « Hauteur » ajoute un décalage manuel. Plus gourmand en CPU que « Avec hauteur ». Non pour la radio, les aperçus ou Orbit.',
|
||||
playbackRateNeutral: 'À 1,0× sans décalage de hauteur, la lecture est normale.',
|
||||
playbackRateOrbitPaused: 'Non appliqué pendant les sessions Orbit — la lecture reste à 1,0× pour la synchro.',
|
||||
playbackRateOrbitPausedShort: 'Orbit : 1,0× pour la synchro.',
|
||||
advancedMode: 'Avancé',
|
||||
advancedModeTooltip: 'Afficher les options avancées dans tous les onglets des paramètres. Contributions de la communauté qui ne reflètent pas nécessairement la philosophie de conception des mainteneurs de Psysonic.',
|
||||
advancedBadge: 'Avancé',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Skjul kø, endre bredde',
|
||||
moreOptions: 'Flere alternativer',
|
||||
equalizer: 'Equalizer',
|
||||
playbackRate: 'Hastighet',
|
||||
miniPlayer: 'Minispiller',
|
||||
lyrics: 'Sangtekst',
|
||||
fsLyricsToggle: 'Sangtekst i fullskjerm',
|
||||
|
||||
@@ -322,8 +322,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Stjernevurdering',
|
||||
playerBarFavorite: 'Favoritt (hjerte)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Avspillingshastighet',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Miniavspiller',
|
||||
playbackRateTitle: 'Avspillingshastighet',
|
||||
playbackRateEnabled: 'Aktiver avspillingshastighet',
|
||||
playbackRateEnabledDesc: 'Global hastighet for alle spor. Gjelder ikke radio og forhåndsvisninger.',
|
||||
playbackRateStrategy: 'Strategi',
|
||||
playbackRateStrategySpeed: 'Hastighet',
|
||||
playbackRateStrategyVarispeed: 'Med tone',
|
||||
playbackRateStrategyPreserve: 'Tonehøyde',
|
||||
playbackRateSpeed: 'Hastighet',
|
||||
playbackRatePitch: 'Tonehøyde',
|
||||
playbackRateDerivedPitch: 'Tonehøydeforskyvning fra hastighet: {{value}}',
|
||||
playbackRateAutoPitch: 'Tonehøyden korrigeres automatisk.',
|
||||
playbackRateHint: '« Hastighet » beholder naturlig tonehøyde. « Med tone » endrer tonehøyden med hastigheten. « Tonehøyde » legger til manuell forskyvning. Mer CPU enn « Med tone ». Ikke for radio, forhåndsvisninger eller Orbit.',
|
||||
playbackRateNeutral: 'Ved 1,0× og uten toneforskyvning er avspilling normal.',
|
||||
playbackRateOrbitPaused: 'Ikke aktiv under Orbit-økter — avspilling forblir 1,0× for synk.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1,0× for synk.',
|
||||
advancedMode: 'Avansert',
|
||||
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
|
||||
advancedBadge: 'Avansert',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Wachtrij inklappen, breedte wijzigen',
|
||||
moreOptions: 'Meer opties',
|
||||
equalizer: 'Equalizer',
|
||||
playbackRate: 'Snelheid',
|
||||
miniPlayer: 'Minispeler',
|
||||
lyrics: 'Songtekst',
|
||||
fsLyricsToggle: 'Songtekst in volledig scherm',
|
||||
|
||||
@@ -323,8 +323,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Sterbeoordeling',
|
||||
playerBarFavorite: 'Favoriet (hart)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Afspeelsnelheid',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini-speler',
|
||||
playbackRateTitle: 'Afspeelsnelheid',
|
||||
playbackRateEnabled: 'Afspeelsnelheid inschakelen',
|
||||
playbackRateEnabledDesc: 'Globale snelheid voor alle nummers. Niet van toepassing op radio en previews.',
|
||||
playbackRateStrategy: 'Strategie',
|
||||
playbackRateStrategySpeed: 'Snelheid',
|
||||
playbackRateStrategyVarispeed: 'Met toon',
|
||||
playbackRateStrategyPreserve: 'Toonhoogte',
|
||||
playbackRateSpeed: 'Snelheid',
|
||||
playbackRatePitch: 'Toonhoogte',
|
||||
playbackRateDerivedPitch: 'Toonhoogteverschuiving door snelheid: {{value}}',
|
||||
playbackRateAutoPitch: 'De toonhoogte wordt automatisch gecorrigeerd.',
|
||||
playbackRateHint: '« Snelheid » behoudt de natuurlijke toonhoogte. « Met toon » verandert de toonhoogte met de snelheid. « Toonhoogte » voegt een handmatige verschuiving toe. Meer CPU dan « Met toon ». Niet voor radio, previews of Orbit.',
|
||||
playbackRateNeutral: 'Bij 1,0× en zonder toonverschuiving is afspelen normaal.',
|
||||
playbackRateOrbitPaused: 'Niet actief tijdens Orbit-sessies — afspelen blijft 1,0× voor sync.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1,0× voor sync.',
|
||||
advancedMode: 'Geavanceerd',
|
||||
advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.',
|
||||
advancedBadge: 'Geavanceerd',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Strânge coada, redimensionează',
|
||||
moreOptions: 'Mai multe opțiuni',
|
||||
equalizer: 'Egalizator',
|
||||
playbackRate: 'Viteză',
|
||||
miniPlayer: 'Player Mini',
|
||||
lyrics: 'Versuri',
|
||||
fsLyricsToggle: 'Versuri în ecran complet',
|
||||
|
||||
@@ -328,8 +328,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Evaluare cu stele',
|
||||
playerBarFavorite: 'Favorit (inimă)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Viteză redare',
|
||||
playerBarEqualizer: 'Egalizator',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playbackRateTitle: 'Viteză redare',
|
||||
playbackRateEnabled: 'Activează viteza de redare',
|
||||
playbackRateEnabledDesc: 'Viteză globală pentru toate piesele. Nu se aplică la radio și previzualizări.',
|
||||
playbackRateStrategy: 'Strategie',
|
||||
playbackRateStrategySpeed: 'Viteză',
|
||||
playbackRateStrategyVarispeed: 'Cu ton',
|
||||
playbackRateStrategyPreserve: 'Ton',
|
||||
playbackRateSpeed: 'Viteză',
|
||||
playbackRatePitch: 'Ton',
|
||||
playbackRateDerivedPitch: 'Deplasare ton din viteză: {{value}}',
|
||||
playbackRateAutoPitch: 'Tonul este corectat automat.',
|
||||
playbackRateHint: '« Viteză » păstrează tonul natural. « Cu ton » schimbă tonul odată cu viteza. « Ton » adaugă o deplasare manuală. Consumă mai mult CPU decât « Cu ton ». Nu pentru radio, previzualizări sau Orbit.',
|
||||
playbackRateNeutral: 'La 1,0× și fără deplasare de ton, redarea este normală.',
|
||||
playbackRateOrbitPaused: 'Nu se aplică în sesiunile Orbit — redarea rămâne la 1,0× pentru sincronizare.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1,0× pentru sync.',
|
||||
advancedMode: 'Avansat',
|
||||
advancedModeTooltip: 'Afișează opțiuni avansate în toate filele de setări. Contribuții ale comunității care nu reflectă neapărat filozofia de design a mentenanților Psysonic.',
|
||||
advancedBadge: 'Avansat',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: 'Свернуть очередь, изменить ширину',
|
||||
moreOptions: 'Дополнительно',
|
||||
equalizer: 'Эквалайзер',
|
||||
playbackRate: 'Скорость',
|
||||
miniPlayer: 'Мини-плеер',
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
|
||||
@@ -339,8 +339,24 @@ export const settings = {
|
||||
playerBarStarRating: 'Оценка звёздами',
|
||||
playerBarFavorite: 'Избранное (сердечко)',
|
||||
playerBarLastfmLove: 'Last.fm love',
|
||||
playerBarPlaybackRate: 'Скорость',
|
||||
playerBarEqualizer: 'Эквалайзер',
|
||||
playerBarMiniPlayer: 'Мини-плеер',
|
||||
playbackRateTitle: 'Скорость воспроизведения',
|
||||
playbackRateEnabled: 'Включить изменение скорости',
|
||||
playbackRateEnabledDesc: 'Глобальная скорость для всех треков. Не применяется к радио и превью.',
|
||||
playbackRateStrategy: 'Стратегия',
|
||||
playbackRateStrategySpeed: 'Скорость',
|
||||
playbackRateStrategyVarispeed: 'С тоном',
|
||||
playbackRateStrategyPreserve: 'Сдвиг тона',
|
||||
playbackRateSpeed: 'Скорость',
|
||||
playbackRatePitch: 'Тон',
|
||||
playbackRateDerivedPitch: 'Сдвиг тона от скорости: {{value}}',
|
||||
playbackRateAutoPitch: 'Тон корректируется автоматически.',
|
||||
playbackRateHint: '«Скорость» сохраняет высоту тона при изменении темпа. «С тоном» меняет тон вместе со скоростью. «Сдвиг тона» — ручное смещение. Выше нагрузка на процессор, чем у режима «С тоном». Не для радио, превью и Orbit.',
|
||||
playbackRateNeutral: 'При 1.0× и нулевом сдвиге тона воспроизведение обычное.',
|
||||
playbackRateOrbitPaused: 'Во время сессии Orbit не применяется — воспроизведение остаётся 1.0× для синхронизации.',
|
||||
playbackRateOrbitPausedShort: 'Orbit: 1.0× для синхронизации.',
|
||||
advancedMode: 'Дополнительно',
|
||||
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
|
||||
advancedBadge: 'Дополнительно',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const player = {
|
||||
collapseQueueResize: '收起队列,调整宽度',
|
||||
moreOptions: '更多选项',
|
||||
equalizer: '均衡器',
|
||||
playbackRate: '速度',
|
||||
miniPlayer: '迷你播放器',
|
||||
lyrics: '歌词',
|
||||
fsLyricsToggle: '全屏歌词',
|
||||
|
||||
@@ -322,8 +322,24 @@ export const settings = {
|
||||
playerBarStarRating: '星级评分',
|
||||
playerBarFavorite: '收藏(心形)',
|
||||
playerBarLastfmLove: 'Last.fm 喜欢',
|
||||
playerBarPlaybackRate: '播放速度',
|
||||
playerBarEqualizer: '均衡器',
|
||||
playerBarMiniPlayer: '迷你播放器',
|
||||
playbackRateTitle: '播放速度',
|
||||
playbackRateEnabled: '启用播放速度',
|
||||
playbackRateEnabledDesc: '全局调整所有曲目的播放速度。不适用于电台和预览。',
|
||||
playbackRateStrategy: '策略',
|
||||
playbackRateStrategySpeed: '速度',
|
||||
playbackRateStrategyVarispeed: '随速变调',
|
||||
playbackRateStrategyPreserve: '音高',
|
||||
playbackRateSpeed: '速度',
|
||||
playbackRatePitch: '音高',
|
||||
playbackRateDerivedPitch: '速度引起的音高偏移:{{value}}',
|
||||
playbackRateAutoPitch: '音高会自动校正。',
|
||||
playbackRateHint: '「速度」保持音高不变。「随速变调」会随速度改变音高。「音高」可手动偏移。比「随速变调」占用更多 CPU。不适用于电台、预览或 Orbit。',
|
||||
playbackRateNeutral: '在 1.0× 且无音高偏移时,播放为正常速度。',
|
||||
playbackRateOrbitPaused: 'Orbit 会话期间不生效 — 播放保持 1.0× 以同步。',
|
||||
playbackRateOrbitPausedShort: 'Orbit:1.0× 同步。',
|
||||
advancedMode: '高级',
|
||||
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
||||
advancedBadge: '高级',
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onInvoke } from '../test/mocks/tauri';
|
||||
import { useLibraryIndexStore } from './libraryIndexStore';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { usePreviewStore } from './previewStore';
|
||||
import {
|
||||
_resetPlayListenSessionForTest,
|
||||
playListenSessionFinalize,
|
||||
@@ -31,14 +33,12 @@ const testTrack: Track = {
|
||||
};
|
||||
|
||||
describe('playListenSession', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
_resetPlayListenSessionForTest();
|
||||
useLibraryIndexStore.setState({
|
||||
masterEnabled: true,
|
||||
syncExcludedByServer: {},
|
||||
});
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const { usePreviewStore } = await import('./previewStore');
|
||||
usePlayerStore.setState({
|
||||
currentRadio: null,
|
||||
isPlaying: true,
|
||||
@@ -76,7 +76,6 @@ describe('playListenSession', () => {
|
||||
});
|
||||
|
||||
it('picks up engine duration from progress ticks', async () => {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
usePlayerStore.setState({
|
||||
currentTrack: { ...testTrack, duration: 0 },
|
||||
});
|
||||
@@ -118,7 +117,6 @@ describe('playListenSession', () => {
|
||||
});
|
||||
|
||||
it('skips radio playback', async () => {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
usePlayerStore.setState({
|
||||
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { usePreviewStore } from './previewStore';
|
||||
import {
|
||||
libraryRecordPlaySession,
|
||||
type PlaySessionEndReason,
|
||||
@@ -48,9 +50,7 @@ function noteDurationHint(durationSec?: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function playerGateBlocks(): Promise<boolean> {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const { usePreviewStore } = await import('./previewStore');
|
||||
function playerGateBlocks(): boolean {
|
||||
if (usePreviewStore.getState().previewingId) return true;
|
||||
if (usePlayerStore.getState().currentRadio) return true;
|
||||
return false;
|
||||
@@ -59,7 +59,7 @@ async function playerGateBlocks(): Promise<boolean> {
|
||||
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
if (await playerGateBlocks()) return false;
|
||||
if (playerGateBlocks()) return false;
|
||||
return libraryIsReady(serverId);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ export async function playListenSessionOnProgress(
|
||||
): Promise<void> {
|
||||
if (!open?.recordingEnabled) return;
|
||||
noteDurationHint(durationSecHint);
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (track?.id === open.trackId) {
|
||||
@@ -126,7 +125,6 @@ export async function playListenSessionFinalize(reason: PlaySessionEndReason): P
|
||||
return;
|
||||
}
|
||||
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
const durationSecHint = resolveDurationSecHint(
|
||||
track?.id === session.trackId ? track : null,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
clampPlaybackPitch,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_PLAYBACK_STRATEGY,
|
||||
effectivePlaybackPitch,
|
||||
type PlaybackStrategy,
|
||||
} from '../utils/audio/playbackRateHelpers';
|
||||
import {
|
||||
restartPlaybackForRateChange,
|
||||
shouldRestartPlaybackForRateChange,
|
||||
type PlaybackRateSnapshot,
|
||||
} from '../utils/audio/playbackRateRestart';
|
||||
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
|
||||
|
||||
interface PlaybackRateState extends PlaybackRateSnapshot {
|
||||
|
||||
setEnabled: (v: boolean) => void;
|
||||
setStrategy: (s: PlaybackStrategy) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
setPitchSemitones: (semitones: number) => void;
|
||||
applyPresetSpeed: (speed: number) => void;
|
||||
syncToRust: () => void;
|
||||
}
|
||||
|
||||
function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapshot) {
|
||||
// Orbit sync assumes 1.0× wall-clock playback; suppress DSP without mutating prefs.
|
||||
const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive();
|
||||
invoke('audio_set_playback_rate', {
|
||||
enabled: effectiveEnabled,
|
||||
strategy: state.strategy,
|
||||
speed: state.speed,
|
||||
pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones),
|
||||
}).catch(() => {});
|
||||
|
||||
if (prev && shouldRestartPlaybackForRateChange(prev, state)) {
|
||||
restartPlaybackForRateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export const usePlaybackRateStore = create<PlaybackRateState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
enabled: false,
|
||||
strategy: DEFAULT_PLAYBACK_STRATEGY,
|
||||
speed: 1.0,
|
||||
pitchSemitones: 0,
|
||||
|
||||
setEnabled: (v) => {
|
||||
const prev = get();
|
||||
set({ enabled: v });
|
||||
syncPlaybackRate(get(), prev);
|
||||
},
|
||||
|
||||
setStrategy: (strategy) => {
|
||||
const prev = get();
|
||||
set({ strategy });
|
||||
syncPlaybackRate(get(), prev);
|
||||
},
|
||||
|
||||
setSpeed: (speed) => {
|
||||
const prev = get();
|
||||
const clamped = clampPlaybackSpeed(speed);
|
||||
set({ speed: clamped });
|
||||
syncPlaybackRate(get(), prev);
|
||||
},
|
||||
|
||||
setPitchSemitones: (semitones) => {
|
||||
const prev = get();
|
||||
const clamped = clampPlaybackPitch(semitones);
|
||||
set({ pitchSemitones: clamped });
|
||||
syncPlaybackRate(get(), prev);
|
||||
},
|
||||
|
||||
applyPresetSpeed: (speed) => {
|
||||
const prev = get();
|
||||
const clamped = clampPlaybackSpeed(speed);
|
||||
set({ speed: clamped });
|
||||
syncPlaybackRate(get(), prev);
|
||||
},
|
||||
|
||||
syncToRust: () => {
|
||||
syncPlaybackRate(get());
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'psysonic-playback-rate',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (s) => ({
|
||||
enabled: s.enabled,
|
||||
strategy: s.strategy,
|
||||
speed: s.speed,
|
||||
pitchSemitones: s.pitchSemitones,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -9,10 +9,10 @@ describe('playerBarLayoutStore', () => {
|
||||
usePlayerBarLayoutStore.getState().reset();
|
||||
});
|
||||
|
||||
it('starts with all five items visible in declared order', () => {
|
||||
it('starts with all six items visible in declared order', () => {
|
||||
const items = usePlayerBarLayoutStore.getState().items;
|
||||
expect(items.map(i => i.id)).toEqual([
|
||||
'starRating', 'favorite', 'lastfmLove', 'equalizer', 'miniPlayer',
|
||||
'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
|
||||
]);
|
||||
expect(items.every(i => i.visible)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PlayerBarLayoutItemId =
|
||||
| 'starRating'
|
||||
| 'favorite'
|
||||
| 'lastfmLove'
|
||||
| 'playbackRate'
|
||||
| 'equalizer'
|
||||
| 'miniPlayer';
|
||||
|
||||
@@ -17,6 +18,7 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
|
||||
{ id: 'starRating', visible: true },
|
||||
{ id: 'favorite', visible: true },
|
||||
{ id: 'lastfmLove', visible: true },
|
||||
{ id: 'playbackRate', visible: true },
|
||||
{ id: 'equalizer', visible: true },
|
||||
{ id: 'miniPlayer', visible: true },
|
||||
];
|
||||
|
||||
@@ -127,4 +127,3 @@ usePlayerStore.subscribe((state, prev) => {
|
||||
buffered: state.buffered,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Side-effect wiring: keep the Rust preview sink volume aligned with the main
|
||||
* player slider while a track preview is active.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { computePreviewVolume, usePreviewStore } from './previewStore';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.volume === prev.volume) return;
|
||||
if (!usePreviewStore.getState().previewingId) return;
|
||||
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { usePreviewStore } from './previewStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOrbitStore } from './orbitStore';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import './previewPlayerVolumeSync';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOrbitStore } from './orbitStore';
|
||||
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
|
||||
|
||||
/** Minimal track info needed to surface the preview in the player bar UI. */
|
||||
export interface PreviewingTrack {
|
||||
@@ -55,7 +55,7 @@ const PREVIEW_VOLUME_MATCH = true;
|
||||
* preview-specific gain, so we pre-multiply here). Master headroom is added on
|
||||
* the Rust side.
|
||||
*/
|
||||
function computePreviewVolume(): number {
|
||||
export function computePreviewVolume(): number {
|
||||
const auth = useAuthStore.getState();
|
||||
let volume = usePlayerStore.getState().volume;
|
||||
if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') {
|
||||
@@ -82,10 +82,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
// preview started by a guest would yank the host's track out from
|
||||
// under them. UI buttons are hidden via `[data-orbit-active]` CSS;
|
||||
// this guards keyboard shortcuts / programmatic callers.
|
||||
const orbit = useOrbitStore.getState();
|
||||
const inOrbit = (orbit.role === 'host' || orbit.role === 'guest')
|
||||
&& (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting');
|
||||
if (inOrbit) return;
|
||||
if (isOrbitPlaybackSyncActive()) return;
|
||||
|
||||
const current = get().previewingId;
|
||||
if (current === song.id) {
|
||||
@@ -158,14 +155,3 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||
},
|
||||
}));
|
||||
|
||||
// Keep the preview sink in sync with player volume slider movements while a
|
||||
// preview is in flight. Without this the Rust preview Sink stays at whatever
|
||||
// level was set at `audio_preview_play` — slider drags only ramp the main
|
||||
// sink. Auth/normalization changes during preview are intentionally ignored
|
||||
// (preview is short and the case is rare).
|
||||
usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.volume === prev.volume) return;
|
||||
if (!usePreviewStore.getState().previewingId) return;
|
||||
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
@import './now-playing-page-2.css';
|
||||
@import './radio-nowplaying-section.css';
|
||||
@import './equalizer.css';
|
||||
@import './playback-rate.css';
|
||||
@import './custom-select.css';
|
||||
@import './changelog.css';
|
||||
@import './genre-filter-bar.css';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
.playback-rate-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.playback-rate-strategy-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playback-rate-strategy-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playback-rate-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.playback-rate-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 4.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playback-rate-slider {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.playback-rate-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.playback-rate-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.player-playback-rate-popover,
|
||||
.player-playback-rate-menu-section {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.player-playback-rate-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.player-playback-rate-menu-section {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.playback-rate-controls--compact .playback-rate-slider-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playback-rate-controls--compact .playback-rate-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playback-rate-controls--compact .playback-rate-strategy-row {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.player-playback-rate-btn {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.player-playback-rate-btn--live {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
isPlaybackEffectActive,
|
||||
isPlaybackRateApplied,
|
||||
derivedVarispeedSemitones,
|
||||
} from './playbackRateHelpers';
|
||||
|
||||
describe('playbackRateHelpers', () => {
|
||||
it('is inactive when disabled', () => {
|
||||
expect(isPlaybackEffectActive(false, 'speed_corrected', 1.5, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('is inactive at 1.0x and 0 pitch when enabled', () => {
|
||||
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('is active when speed differs from 1', () => {
|
||||
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.25, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserve_pitch is active at 1.0x with pitch offset', () => {
|
||||
expect(isPlaybackEffectActive(true, 'preserve_pitch', 1.0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('speed_corrected ignores stored pitch at 1.0x', () => {
|
||||
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps speed', () => {
|
||||
expect(clampPlaybackSpeed(3)).toBe(2);
|
||||
expect(clampPlaybackSpeed(0.1)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('derives semitones for varispeed', () => {
|
||||
expect(derivedVarispeedSemitones(2)).toBeCloseTo(12, 1);
|
||||
});
|
||||
|
||||
it('is not applied during orbit', () => {
|
||||
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, true)).toBe(false);
|
||||
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export type PlaybackStrategy = 'speed_corrected' | 'varispeed' | 'preserve_pitch';
|
||||
|
||||
/** Default strategy: speed only, pitch corrected automatically. */
|
||||
export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected';
|
||||
|
||||
export const PLAYBACK_STRATEGIES: PlaybackStrategy[] = [
|
||||
'speed_corrected',
|
||||
'varispeed',
|
||||
'preserve_pitch',
|
||||
];
|
||||
|
||||
export const PLAYBACK_SPEED_MIN = 0.5;
|
||||
export const PLAYBACK_SPEED_MAX = 2.0;
|
||||
export const PLAYBACK_SPEED_STEP = 0.05;
|
||||
export const PLAYBACK_PITCH_MIN = -12;
|
||||
export const PLAYBACK_PITCH_MAX = 12;
|
||||
export const PLAYBACK_PITCH_STEP = 0.1;
|
||||
export const PLAYBACK_SPEED_PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0] as const;
|
||||
|
||||
export function clampPlaybackSpeed(speed: number): number {
|
||||
return Math.max(PLAYBACK_SPEED_MIN, Math.min(PLAYBACK_SPEED_MAX, speed));
|
||||
}
|
||||
|
||||
export function clampPlaybackPitch(semitones: number): number {
|
||||
return Math.max(PLAYBACK_PITCH_MIN, Math.min(PLAYBACK_PITCH_MAX, semitones));
|
||||
}
|
||||
|
||||
/** Pitch sent to Rust: manual offset only in preserve_pitch strategy. */
|
||||
export function effectivePlaybackPitch(
|
||||
strategy: PlaybackStrategy,
|
||||
pitchSemitones: number,
|
||||
): number {
|
||||
return strategy === 'preserve_pitch' ? pitchSemitones : 0;
|
||||
}
|
||||
|
||||
/** True when DSP should run (enabled + not neutral 1.0× / 0 st). */
|
||||
export function isPlaybackEffectActive(
|
||||
enabled: boolean,
|
||||
strategy: PlaybackStrategy,
|
||||
speed: number,
|
||||
pitchSemitones: number,
|
||||
): boolean {
|
||||
if (!enabled) return false;
|
||||
if (strategy === 'preserve_pitch') {
|
||||
return Math.abs(speed - 1) > 0.001 || Math.abs(pitchSemitones) > 0.001;
|
||||
}
|
||||
return Math.abs(speed - 1) > 0.001;
|
||||
}
|
||||
|
||||
/** True when the engine applies playback-rate DSP (Orbit sessions force passthrough). */
|
||||
export function isPlaybackRateApplied(
|
||||
enabled: boolean,
|
||||
strategy: PlaybackStrategy,
|
||||
speed: number,
|
||||
pitchSemitones: number,
|
||||
orbitSessionActive: boolean,
|
||||
): boolean {
|
||||
if (orbitSessionActive) return false;
|
||||
return isPlaybackEffectActive(enabled, strategy, speed, pitchSemitones);
|
||||
}
|
||||
|
||||
export function derivedVarispeedSemitones(speed: number): number {
|
||||
if (speed <= 0) return 0;
|
||||
return 12 * Math.log2(speed);
|
||||
}
|
||||
|
||||
export function formatSpeedLabel(speed: number): string {
|
||||
return `${speed.toFixed(1)}×`;
|
||||
}
|
||||
|
||||
export function formatPitchLabel(semitones: number): string {
|
||||
const rounded = Math.round(semitones * 10) / 10;
|
||||
return rounded > 0 ? `+${rounded.toFixed(1)} st` : `${rounded.toFixed(1)} st`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPlaybackEffectActive } from './playbackRateHelpers';
|
||||
import {
|
||||
playbackPathChanged,
|
||||
shouldRestartPlaybackForRateChange,
|
||||
usesPreservePlaybackPath,
|
||||
} from './playbackRateRestart';
|
||||
|
||||
describe('playbackRateRestart', () => {
|
||||
const base = {
|
||||
enabled: true,
|
||||
strategy: 'speed_corrected' as const,
|
||||
speed: 1.5,
|
||||
pitchSemitones: 0,
|
||||
};
|
||||
|
||||
it('detects preserve vs varispeed paths', () => {
|
||||
expect(usesPreservePlaybackPath('speed_corrected')).toBe(true);
|
||||
expect(usesPreservePlaybackPath('preserve_pitch')).toBe(true);
|
||||
expect(usesPreservePlaybackPath('varispeed')).toBe(false);
|
||||
expect(playbackPathChanged('speed_corrected', 'varispeed')).toBe(true);
|
||||
expect(playbackPathChanged('speed_corrected', 'preserve_pitch')).toBe(false);
|
||||
});
|
||||
|
||||
it('restarts on strategy change', () => {
|
||||
expect(shouldRestartPlaybackForRateChange(
|
||||
base,
|
||||
{ ...base, strategy: 'varispeed' },
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('restarts when effect becomes active', () => {
|
||||
expect(shouldRestartPlaybackForRateChange(
|
||||
{ ...base, speed: 1.0 },
|
||||
{ ...base, speed: 1.5 },
|
||||
)).toBe(true);
|
||||
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false);
|
||||
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.5, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not restart on speed tweak within varispeed', () => {
|
||||
expect(shouldRestartPlaybackForRateChange(
|
||||
{ ...base, strategy: 'varispeed', speed: 1.5 },
|
||||
{ ...base, strategy: 'varispeed', speed: 1.75 },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not restart on pitch tweak within preserve_pitch', () => {
|
||||
expect(shouldRestartPlaybackForRateChange(
|
||||
{ ...base, strategy: 'preserve_pitch', pitchSemitones: 0 },
|
||||
{ ...base, strategy: 'preserve_pitch', pitchSemitones: 2 },
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
|
||||
import { isPlaybackEffectActive, type PlaybackStrategy } from './playbackRateHelpers';
|
||||
|
||||
/** Preserve-pitch DSP (worker) vs direct varispeed sample-rate scaling. */
|
||||
export function usesPreservePlaybackPath(strategy: PlaybackStrategy): boolean {
|
||||
return strategy === 'speed_corrected' || strategy === 'preserve_pitch';
|
||||
}
|
||||
|
||||
export function playbackPathChanged(a: PlaybackStrategy, b: PlaybackStrategy): boolean {
|
||||
return usesPreservePlaybackPath(a) !== usesPreservePlaybackPath(b);
|
||||
}
|
||||
|
||||
export interface PlaybackRateSnapshot {
|
||||
enabled: boolean;
|
||||
strategy: PlaybackStrategy;
|
||||
speed: number;
|
||||
pitchSemitones: number;
|
||||
}
|
||||
|
||||
/** Whether live atomics are enough vs needing a source rebuild (spec §2.5). */
|
||||
export function shouldRestartPlaybackForRateChange(
|
||||
prev: PlaybackRateSnapshot,
|
||||
next: PlaybackRateSnapshot,
|
||||
): boolean {
|
||||
if (prev.strategy !== next.strategy) return true;
|
||||
if (prev.enabled !== next.enabled && isPlaybackEffectActive(
|
||||
next.enabled,
|
||||
next.strategy,
|
||||
next.speed,
|
||||
next.pitchSemitones,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
const prevActive = isPlaybackEffectActive(
|
||||
prev.enabled,
|
||||
prev.strategy,
|
||||
prev.speed,
|
||||
prev.pitchSemitones,
|
||||
);
|
||||
const nextActive = isPlaybackEffectActive(
|
||||
next.enabled,
|
||||
next.strategy,
|
||||
next.speed,
|
||||
next.pitchSemitones,
|
||||
);
|
||||
if (prevActive !== nextActive) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-decode current track at the current timeline position (seek-restart). */
|
||||
export function restartPlaybackForRateChange(): void {
|
||||
const player = usePlayerStore.getState();
|
||||
const track = player.currentTrack;
|
||||
if (!track || !player.isPlaying) return;
|
||||
if (track.radioAdded) return;
|
||||
|
||||
const dur = track.duration;
|
||||
if (!dur || !Number.isFinite(dur) || dur <= 0) return;
|
||||
|
||||
const time = Math.max(0, Math.min(player.currentTime, dur - 0.25));
|
||||
if (time > 0.05) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: track.id,
|
||||
seconds: time,
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
player.playTrack(track, player.queue, true);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
serialiseOrbitState,
|
||||
suggestionKey,
|
||||
} from './orbit/helpers';
|
||||
export { isOrbitPlaybackSyncActive } from './orbit/sessionActive';
|
||||
export {
|
||||
applyOutboxSnapshotsToState,
|
||||
computeOrbitDriftMs,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OrbitPhase, OrbitRole } from '../../store/orbitStore';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
|
||||
/**
|
||||
* True while this client is in an Orbit session on the shared playback path
|
||||
* (host/guest during start, join, or active play). Matches previewStore guard.
|
||||
*/
|
||||
export function isOrbitPlaybackSyncActive(
|
||||
role: OrbitRole | null = useOrbitStore.getState().role,
|
||||
phase: OrbitPhase = useOrbitStore.getState().phase,
|
||||
): boolean {
|
||||
if (role !== 'host' && role !== 'guest') return false;
|
||||
return phase === 'active' || phase === 'joining' || phase === 'starting';
|
||||
}
|
||||
Reference in New Issue
Block a user