mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
e8e41752a7
* 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>
120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
/** Wires the utility-overflow Ellipsis button + its portaled menu:
|
|
* - watches the player-bar width via ResizeObserver and flips `utilityOverflow`
|
|
* when it crosses the threshold (980 px floating / 1140 px docked)
|
|
* - owns the menu open state and the 'full' vs. 'volume' display mode
|
|
* - closes the menu on outside click or Escape
|
|
* - re-positions the menu (fixed, above the trigger) on resize/scroll
|
|
* - exposes a volumeWheelMenuTimerRef so the volume-wheel auto-hide handler
|
|
* can reuse the same timer. */
|
|
export function useUtilityOverflowMenu(
|
|
playerBarRef: React.RefObject<HTMLElement | null>,
|
|
floatingPlayerBar: boolean,
|
|
) {
|
|
const [utilityOverflow, setUtilityOverflow] = useState(false);
|
|
const [utilityMenuOpen, setUtilityMenuOpen] = useState(false);
|
|
const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full');
|
|
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
|
|
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
|
|
const utilityMenuRef = useRef<HTMLDivElement>(null);
|
|
const utilityBtnRef = useRef<HTMLButtonElement>(null);
|
|
const volumeWheelMenuTimerRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const updateOverflow = () => {
|
|
const width = playerBarRef.current?.clientWidth ?? window.innerWidth;
|
|
const threshold = floatingPlayerBar ? 980 : 1140;
|
|
setUtilityOverflow(width < threshold);
|
|
};
|
|
|
|
updateOverflow();
|
|
const ro = typeof ResizeObserver !== 'undefined'
|
|
? new ResizeObserver(updateOverflow)
|
|
: null;
|
|
const el = playerBarRef.current;
|
|
if (ro && el) ro.observe(el);
|
|
window.addEventListener('resize', updateOverflow);
|
|
return () => {
|
|
ro?.disconnect();
|
|
window.removeEventListener('resize', updateOverflow);
|
|
};
|
|
}, [floatingPlayerBar, playerBarRef]);
|
|
|
|
useEffect(() => {
|
|
if (!utilityOverflow) setUtilityMenuOpen(false);
|
|
if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
volumeWheelMenuTimerRef.current = null;
|
|
}
|
|
}, [utilityOverflow]);
|
|
|
|
useEffect(() => {
|
|
if (!utilityMenuOpen) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
const target = e.target as Node;
|
|
if (utilityBtnRef.current?.contains(target)) return;
|
|
if (utilityMenuRef.current?.contains(target)) return;
|
|
setUtilityMenuOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setUtilityMenuOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDown);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDown);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [utilityMenuOpen]);
|
|
|
|
useEffect(() => () => {
|
|
if (volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!utilityMenuOpen) return;
|
|
const MENU_WIDTH = utilityMenuMode === 'volume' ? 238 : 320;
|
|
const MARGIN = 8;
|
|
const updateMenuPos = () => {
|
|
const btn = utilityBtnRef.current;
|
|
if (!btn) return;
|
|
const r = btn.getBoundingClientRect();
|
|
const left = Math.min(
|
|
Math.max(r.right - MENU_WIDTH, MARGIN),
|
|
window.innerWidth - MENU_WIDTH - MARGIN,
|
|
);
|
|
setUtilityMenuStyle({
|
|
position: 'fixed',
|
|
left,
|
|
width: MENU_WIDTH,
|
|
bottom: window.innerHeight - r.top + 8,
|
|
zIndex: 10050,
|
|
});
|
|
};
|
|
updateMenuPos();
|
|
window.addEventListener('resize', updateMenuPos);
|
|
window.addEventListener('scroll', updateMenuPos, true);
|
|
return () => {
|
|
window.removeEventListener('resize', updateMenuPos);
|
|
window.removeEventListener('scroll', updateMenuPos, true);
|
|
};
|
|
}, [utilityMenuOpen, utilityMenuMode]);
|
|
|
|
return {
|
|
utilityOverflow,
|
|
utilityMenuOpen,
|
|
setUtilityMenuOpen,
|
|
utilityMenuMode,
|
|
setUtilityMenuMode,
|
|
utilityMenuStyle,
|
|
utilityMenuRef,
|
|
utilityBtnRef,
|
|
volumeWheelMenuTimerRef,
|
|
suppressOverflowTooltip,
|
|
setSuppressOverflowTooltip,
|
|
};
|
|
}
|