mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
* fix(audio): promote WASAPI render thread to MMCSS Pro Audio on Windows
Wraps the outermost audio source in a `PriorityBoostSource` that calls
`AvSetMmThreadCharacteristicsW("Pro Audio")` on its first sample. The
cpal output-stream callback runs `Source::next` on the WASAPI render
thread, which is otherwise normal-priority and gets preempted under
WebView2 / DWM / GPU pressure — producing the audible click/stutter
reported in issue #334. No-op on Linux/macOS (PipeWire/rtkit and
CoreAudio promote their audio threads externally).
* fix(build): repair Windows compile after audio split + lib decompose
Two pre-existing build breakers on Windows that surfaced after the
`use super::*;` cleanup (9cc74a7) and the lib-decompose refactor
(cfeec22):
- `audio/device_watcher.rs` lost the `output_enumeration_includes_pinned`
import. Add it cfg-gated to `not(target_os = "linux")` since it's only
called inside the non-Linux pinned-device fallback path.
- `lib_commands/sync/tray.rs::is_tiling_wm()` is `#[cfg(target_os = "linux")]`,
but its Tauri command wrapper `is_tiling_wm_cmd()` is unconditional.
Add a `#[cfg(not(target_os = "linux"))]` stub returning `false`.
No behavior change on Linux. Restores `cargo build` on Windows + macOS.
* perf(ui): pause cosmetic animations when window loses OS focus
Companion to the WASAPI MMCSS fix in this branch — issue #334
reported audible audio stutter on low-end laptops, partly traced
to WebView2 still compositing CSS animations + waveform canvas at
full rate while the user has alt-tabbed into another app.
Existing `data-app-hidden` / `data-psy-native-hidden` flags pause
animations only when the window is fully hidden (minimized or
behind a tray). When the window is visible-but-unfocused — the
common "music in the background while I work" case — every infinite
keyframe + 60fps rAF loop keeps running.
Add a parallel `data-app-blurred` flag driven by `window.focus`/
`window.blur`, plus matching `__psyBlurred` on the global window
object for imperative checks. The CSS rule that pauses every
`animation-play-state` on hidden gets a third selector for blurred.
WaveformSeek's two 60fps rAF loops (animated styles + settings demo)
extend their existing `document.hidden || __psyHidden` short-circuit
to also include the new blurred flag, falling back to the same
400ms polling path that already exists for hidden.
Verified visually on the dashboard: alt-tab → `<html
data-app-blurred="true">`, mesh blobs in the fullscreen player +
marquee + waveform 60fps loop all pause. Click back into the
window → blob/marquee/waveform resume immediately.
Cross-platform: focus/blur events fire reliably on all targets, so
the optimization applies to Windows, Linux (incl. WebKitGTK with
software compositing where the savings are largest), and macOS.
* chore: remove stale app-icon.png from repo root
* perf(ui): add 'Reduce animations' toggle that caps animated seekbar styles to 30 fps
Off by default. When on, animated seekbar styles (pulsewave, particletrail,
liquidfill, retrotape) skip every other rAF and advance the animation timer by
a doubled delta, so wave speed is unchanged while draws are halved. Aimed at
low-end Windows GPUs where the 60 fps shadow-blur loop drives ~40 % WebView2
GPU usage even when focused.
Toggle lives directly under the seekbar style picker in Settings → Appearance.
i18n in all 8 supported languages.
This commit is contained in:
committed by
GitHub
parent
7064ca500e
commit
2e9618cf54
Binary file not shown.
|
Before Width: | Height: | Size: 432 KiB |
@@ -66,6 +66,7 @@ windows = { version = "0.58", features = [
|
|||||||
"Win32_Graphics",
|
"Win32_Graphics",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
|
"Win32_System_Threading",
|
||||||
"Win32_UI_Controls",
|
"Win32_UI_Controls",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
|||||||
@@ -1638,12 +1638,13 @@ pub async fn audio_play_radio(
|
|||||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||||
|
let boosted = PriorityBoostSource::new(counting);
|
||||||
|
|
||||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||||
|
|
||||||
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(counting);
|
sink.append(boosted);
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut cur = state.current.lock().unwrap();
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
|||||||
|
|
||||||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||||||
pub(crate) struct BuiltSource {
|
pub(crate) struct BuiltSource {
|
||||||
pub(crate) source: CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>,
|
pub(crate) source: PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>,
|
||||||
pub(crate) duration_secs: f64,
|
pub(crate) duration_secs: f64,
|
||||||
pub(crate) output_rate: u32,
|
pub(crate) output_rate: u32,
|
||||||
pub(crate) output_channels: u16,
|
pub(crate) output_channels: u16,
|
||||||
@@ -611,9 +611,10 @@ pub(crate) fn build_source(
|
|||||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||||
let counting = CountingSource::new(notifying, sample_counter);
|
let counting = CountingSource::new(notifying, sample_counter);
|
||||||
|
let boosted = PriorityBoostSource::new(counting);
|
||||||
|
|
||||||
Ok(BuiltSource {
|
Ok(BuiltSource {
|
||||||
source: counting,
|
source: boosted,
|
||||||
duration_secs: effective_dur,
|
duration_secs: effective_dur,
|
||||||
output_rate,
|
output_rate,
|
||||||
output_channels: channels,
|
output_channels: channels,
|
||||||
@@ -670,9 +671,10 @@ pub(crate) fn build_streaming_source(
|
|||||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||||
let counting = CountingSource::new(notifying, sample_counter);
|
let counting = CountingSource::new(notifying, sample_counter);
|
||||||
|
let boosted = PriorityBoostSource::new(counting);
|
||||||
|
|
||||||
Ok(BuiltSource {
|
Ok(BuiltSource {
|
||||||
source: counting,
|
source: boosted,
|
||||||
duration_secs: effective_dur,
|
duration_secs: effective_dur,
|
||||||
output_rate,
|
output_rate,
|
||||||
output_channels: channels,
|
output_channels: channels,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use std::time::Duration;
|
|||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
use super::engine::AudioEngine;
|
use super::engine::AudioEngine;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
use super::dev_io::output_enumeration_includes_pinned;
|
||||||
|
|
||||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
use super::decode::SizedDecoder;
|
use super::decode::SizedDecoder;
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine};
|
||||||
use super::helpers::MASTER_HEADROOM;
|
use super::helpers::MASTER_HEADROOM;
|
||||||
|
use super::sources::PriorityBoostSource;
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||||
@@ -189,6 +190,7 @@ pub async fn audio_preview_play(
|
|||||||
}
|
}
|
||||||
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
||||||
let source = source.take_duration(dur);
|
let source = source.take_duration(dur);
|
||||||
|
let source = PriorityBoostSource::new(source);
|
||||||
|
|
||||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||||
let sink = Arc::new(
|
let sink = Arc::new(
|
||||||
|
|||||||
@@ -399,3 +399,83 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PriorityBoostSource — promote the calling thread on first sample ────────
|
||||||
|
//
|
||||||
|
// rodio's `Sink` runs `Source::next` inside the cpal output-stream callback.
|
||||||
|
// On Windows that callback is the WASAPI render thread, which by default has
|
||||||
|
// only normal priority — when WebView2 / DWM / GPU work spikes the system,
|
||||||
|
// the audio thread gets preempted and underruns produce audible click /
|
||||||
|
// stutter. This wrapper sets the MMCSS "Pro Audio" task class on the first
|
||||||
|
// `next()` call so the kernel keeps the render thread on a real-time class
|
||||||
|
// alongside other audio applications. On Linux/macOS the wrapper compiles to
|
||||||
|
// a no-op — those platforms already promote their audio threads externally
|
||||||
|
// (PipeWire/rtkit, CoreAudio).
|
||||||
|
//
|
||||||
|
// Idempotent across track changes: each new track instantiates a fresh
|
||||||
|
// PriorityBoostSource, but `AvSetMmThreadCharacteristicsW` can be called
|
||||||
|
// repeatedly on the same thread.
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn promote_thread_to_pro_audio() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW;
|
||||||
|
|
||||||
|
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
// Null-terminated UTF-16 task name, lifetime-pinned for the call.
|
||||||
|
let task: [u16; 10] = [
|
||||||
|
b'P' as u16, b'r' as u16, b'o' as u16, b' ' as u16,
|
||||||
|
b'A' as u16, b'u' as u16, b'd' as u16, b'i' as u16,
|
||||||
|
b'o' as u16, 0,
|
||||||
|
];
|
||||||
|
let mut idx: u32 = 0;
|
||||||
|
let result = unsafe { AvSetMmThreadCharacteristicsW(PCWSTR(task.as_ptr()), &mut idx) };
|
||||||
|
|
||||||
|
if result.is_ok() && !LOGGED.swap(true, Ordering::Relaxed) {
|
||||||
|
// First-time log: not in the hot path on subsequent track starts.
|
||||||
|
// Logging is file IO (blocking) but we only run it once per process
|
||||||
|
// lifetime, on the very first render-callback invocation.
|
||||||
|
crate::app_eprintln!("[psysonic] WASAPI render thread promoted to MMCSS \"Pro Audio\"");
|
||||||
|
}
|
||||||
|
// Handle leaks intentionally — promotion lasts until the thread exits,
|
||||||
|
// which matches the WASAPI render-thread lifetime.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
#[inline(always)]
|
||||||
|
fn promote_thread_to_pro_audio() {}
|
||||||
|
|
||||||
|
pub(crate) struct PriorityBoostSource<S: Source<Item = f32>> {
|
||||||
|
inner: S,
|
||||||
|
promoted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Source<Item = f32>> PriorityBoostSource<S> {
|
||||||
|
pub(crate) fn new(inner: S) -> Self {
|
||||||
|
Self { inner, promoted: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Source<Item = f32>> Iterator for PriorityBoostSource<S> {
|
||||||
|
type Item = f32;
|
||||||
|
#[inline]
|
||||||
|
fn next(&mut self) -> Option<f32> {
|
||||||
|
if !self.promoted {
|
||||||
|
self.promoted = true;
|
||||||
|
promote_thread_to_pro_audio();
|
||||||
|
}
|
||||||
|
self.inner.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
|
||||||
|
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||||
|
fn channels(&self) -> u16 { self.inner.channels() }
|
||||||
|
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||||
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
|
self.inner.try_seek(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -390,6 +390,11 @@ pub(crate) fn no_compositing_mode() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub(crate) fn is_tiling_wm() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Tauri command: lets the frontend know whether we're running under a tiling
|
/// Tauri command: lets the frontend know whether we're running under a tiling
|
||||||
/// WM so it can decide whether to render the custom TitleBar component.
|
/// WM so it can decide whether to render the custom TitleBar component.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
+19
@@ -580,6 +580,25 @@ function AppShell() {
|
|||||||
return () => document.removeEventListener('visibilitychange', update);
|
return () => document.removeEventListener('visibilitychange', update);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Pause cosmetic animations when the window loses OS focus but stays visible
|
||||||
|
// (alt-tab, click into another app). On low-VRAM laptops WebView2 keeps
|
||||||
|
// compositing mesh blobs / waveform / marquee at full rate even though the
|
||||||
|
// user isn't looking — measurable GPU drain reported in issue #334.
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
const blurred = !document.hasFocus();
|
||||||
|
window.__psyBlurred = blurred;
|
||||||
|
document.documentElement.dataset.appBlurred = blurred ? 'true' : 'false';
|
||||||
|
};
|
||||||
|
window.addEventListener('focus', update);
|
||||||
|
window.addEventListener('blur', update);
|
||||||
|
update();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', update);
|
||||||
|
window.removeEventListener('blur', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ export function SeekbarPreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.hidden || window.__psyHidden) {
|
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||||
pollId = window.setTimeout(() => {
|
pollId = window.setTimeout(() => {
|
||||||
pollId = null;
|
pollId = null;
|
||||||
tick();
|
tick();
|
||||||
@@ -912,11 +912,14 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||||
|
const reducedAnimations = useAuthStore(s => s.reducedAnimations);
|
||||||
|
|
||||||
// Ref so the subscription callback (closed over at mount) can read the
|
// Ref so the subscription callback (closed over at mount) can read the
|
||||||
// current style without stale-closure issues.
|
// current style without stale-closure issues.
|
||||||
const styleRef = useRef(seekbarStyle);
|
const styleRef = useRef(seekbarStyle);
|
||||||
styleRef.current = seekbarStyle;
|
styleRef.current = seekbarStyle;
|
||||||
|
const reducedRef = useRef(reducedAnimations);
|
||||||
|
reducedRef.current = reducedAnimations;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!trackId) {
|
if (!trackId) {
|
||||||
@@ -1051,6 +1054,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
animStateRef.current = makeAnimState();
|
animStateRef.current = makeAnimState();
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
let pollId: number | null = null;
|
let pollId: number | null = null;
|
||||||
|
let skip = false;
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
@@ -1062,14 +1066,22 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.hidden || window.__psyHidden) {
|
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||||
pollId = window.setTimeout(() => {
|
pollId = window.setTimeout(() => {
|
||||||
pollId = null;
|
pollId = null;
|
||||||
tick();
|
tick();
|
||||||
}, 400);
|
}, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
animStateRef.current.time += 0.016;
|
// 30 fps cap when reducedAnimations is on: skip every other rAF, advance
|
||||||
|
// animation time by a doubled delta so wave speed stays the same.
|
||||||
|
if (reducedRef.current && skip) {
|
||||||
|
skip = false;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skip = reducedRef.current;
|
||||||
|
animStateRef.current.time += reducedRef.current ? 0.032 : 0.016;
|
||||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -915,6 +915,8 @@ export const deTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
|
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
|
||||||
seekbarStyle: 'Seekbar-Stil',
|
seekbarStyle: 'Seekbar-Stil',
|
||||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||||
|
reducedAnimations: 'Animationen reduzieren',
|
||||||
|
reducedAnimationsDesc: 'Animierte Seekbar-Stile auf 30 fps drosseln, um die GPU-Last auf schwächerer Hardware zu senken. Optisch kaum wahrnehmbar.',
|
||||||
seekbarTruewave: 'Echte Wellenform',
|
seekbarTruewave: 'Echte Wellenform',
|
||||||
seekbarPseudowave: 'Pseudo-Wellenform',
|
seekbarPseudowave: 'Pseudo-Wellenform',
|
||||||
seekbarLinedot: 'Linie & Punkt',
|
seekbarLinedot: 'Linie & Punkt',
|
||||||
|
|||||||
@@ -921,6 +921,8 @@ export const enTranslation = {
|
|||||||
fsPortraitDim: 'Photo dimming',
|
fsPortraitDim: 'Photo dimming',
|
||||||
seekbarStyle: 'Seekbar Style',
|
seekbarStyle: 'Seekbar Style',
|
||||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||||
|
reducedAnimations: 'Reduce animations',
|
||||||
|
reducedAnimationsDesc: 'Cap animated seekbar styles to 30 fps to lower GPU usage on slower hardware. Visual difference is minimal.',
|
||||||
seekbarTruewave: 'Truewave',
|
seekbarTruewave: 'Truewave',
|
||||||
seekbarPseudowave: 'Pseudowave',
|
seekbarPseudowave: 'Pseudowave',
|
||||||
seekbarLinedot: 'Line & Dot',
|
seekbarLinedot: 'Line & Dot',
|
||||||
|
|||||||
@@ -908,6 +908,8 @@ export const esTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
||||||
seekbarStyle: 'Estilo de Barra de Progreso',
|
seekbarStyle: 'Estilo de Barra de Progreso',
|
||||||
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
||||||
|
reducedAnimations: 'Reducir animaciones',
|
||||||
|
reducedAnimationsDesc: 'Limita los estilos animados de la barra de progreso a 30 fps para reducir el uso de GPU en hardware más lento. La diferencia visual es mínima.',
|
||||||
seekbarTruewave: 'Forma de Onda Real',
|
seekbarTruewave: 'Forma de Onda Real',
|
||||||
seekbarPseudowave: 'Forma de Onda Pseudo',
|
seekbarPseudowave: 'Forma de Onda Pseudo',
|
||||||
seekbarLinedot: 'Línea y Punto',
|
seekbarLinedot: 'Línea y Punto',
|
||||||
|
|||||||
@@ -903,6 +903,8 @@ export const frTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
|
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
|
||||||
seekbarStyle: 'Style de la barre de lecture',
|
seekbarStyle: 'Style de la barre de lecture',
|
||||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||||
|
reducedAnimations: 'Réduire les animations',
|
||||||
|
reducedAnimationsDesc: 'Limiter les styles de barre de lecture animés à 30 fps pour réduire l\'utilisation du GPU sur le matériel plus lent. La différence visuelle est minime.',
|
||||||
seekbarTruewave: 'Forme d\'onde réelle',
|
seekbarTruewave: 'Forme d\'onde réelle',
|
||||||
seekbarPseudowave: 'Forme d\'onde pseudo',
|
seekbarPseudowave: 'Forme d\'onde pseudo',
|
||||||
seekbarLinedot: 'Ligne & point',
|
seekbarLinedot: 'Ligne & point',
|
||||||
|
|||||||
@@ -902,6 +902,8 @@ export const nbTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
|
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
|
||||||
seekbarStyle: 'Søkefelt-stil',
|
seekbarStyle: 'Søkefelt-stil',
|
||||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||||
|
reducedAnimations: 'Reduser animasjoner',
|
||||||
|
reducedAnimationsDesc: 'Begrens animerte søkefelt-stiler til 30 fps for å redusere GPU-belastning på tregere maskinvare. Visuell forskjell er minimal.',
|
||||||
seekbarTruewave: 'Ekte bølgeform',
|
seekbarTruewave: 'Ekte bølgeform',
|
||||||
seekbarPseudowave: 'Pseudo-bølgeform',
|
seekbarPseudowave: 'Pseudo-bølgeform',
|
||||||
seekbarLinedot: 'Linje & punkt',
|
seekbarLinedot: 'Linje & punkt',
|
||||||
|
|||||||
@@ -902,6 +902,8 @@ export const nlTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
|
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
|
||||||
seekbarStyle: 'Zoekbalkstijl',
|
seekbarStyle: 'Zoekbalkstijl',
|
||||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||||
|
reducedAnimations: 'Animaties beperken',
|
||||||
|
reducedAnimationsDesc: 'Beperk geanimeerde zoekbalkstijlen tot 30 fps om GPU-gebruik op tragere hardware te verminderen. Visueel verschil is minimaal.',
|
||||||
seekbarTruewave: 'Echte golfvorm',
|
seekbarTruewave: 'Echte golfvorm',
|
||||||
seekbarPseudowave: 'Pseudo-golfvorm',
|
seekbarPseudowave: 'Pseudo-golfvorm',
|
||||||
seekbarLinedot: 'Lijn & punt',
|
seekbarLinedot: 'Lijn & punt',
|
||||||
|
|||||||
@@ -954,6 +954,8 @@ export const ruTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
|
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
|
||||||
seekbarStyle: 'Стиль прогресс-бара',
|
seekbarStyle: 'Стиль прогресс-бара',
|
||||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||||
|
reducedAnimations: 'Снизить анимации',
|
||||||
|
reducedAnimationsDesc: 'Ограничить анимированные стили прогресс-бара 30 кадрами в секунду для снижения нагрузки на GPU на слабом железе. Визуальная разница минимальна.',
|
||||||
seekbarTruewave: 'Реальная форма волны',
|
seekbarTruewave: 'Реальная форма волны',
|
||||||
seekbarPseudowave: 'Псевдо форма волны',
|
seekbarPseudowave: 'Псевдо форма волны',
|
||||||
seekbarLinedot: 'Линия и точка',
|
seekbarLinedot: 'Линия и точка',
|
||||||
|
|||||||
@@ -897,6 +897,8 @@ export const zhTranslation = {
|
|||||||
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
|
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
|
||||||
seekbarStyle: '进度条样式',
|
seekbarStyle: '进度条样式',
|
||||||
seekbarStyleDesc: '选择播放进度条的外观',
|
seekbarStyleDesc: '选择播放进度条的外观',
|
||||||
|
reducedAnimations: '减少动画',
|
||||||
|
reducedAnimationsDesc: '将动画进度条样式限制为 30 fps,以降低较慢硬件上的 GPU 占用。视觉差异极小。',
|
||||||
seekbarTruewave: '真实波形',
|
seekbarTruewave: '真实波形',
|
||||||
seekbarPseudowave: '伪波形',
|
seekbarPseudowave: '伪波形',
|
||||||
seekbarLinedot: '线条与点',
|
seekbarLinedot: '线条与点',
|
||||||
|
|||||||
+11
-1
@@ -396,7 +396,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
||||||
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
||||||
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
|
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
|
||||||
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform' },
|
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
|
||||||
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
||||||
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
||||||
{ tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
|
{ tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
|
||||||
@@ -3844,6 +3844,16 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="settings-toggle-row" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.reducedAnimations')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.reducedAnimationsDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.reducedAnimations')}>
|
||||||
|
<input type="checkbox" checked={auth.reducedAnimations} onChange={e => auth.setReducedAnimations(e.target.checked)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ interface AuthState {
|
|||||||
lastSeenChangelogVersion: string;
|
lastSeenChangelogVersion: string;
|
||||||
|
|
||||||
seekbarStyle: SeekbarStyle;
|
seekbarStyle: SeekbarStyle;
|
||||||
|
/** Cap animated seekbar styles to 30 fps (and similar GPU-friendly tweaks) for low-end hardware. */
|
||||||
|
reducedAnimations: boolean;
|
||||||
|
|
||||||
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||||
enableHiRes: boolean;
|
enableHiRes: boolean;
|
||||||
@@ -323,6 +325,7 @@ interface AuthState {
|
|||||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||||
setLastSeenChangelogVersion: (v: string) => void;
|
setLastSeenChangelogVersion: (v: string) => void;
|
||||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||||
|
setReducedAnimations: (v: boolean) => void;
|
||||||
setEnableHiRes: (v: boolean) => void;
|
setEnableHiRes: (v: boolean) => void;
|
||||||
setAudioOutputDevice: (v: string | null) => void;
|
setAudioOutputDevice: (v: string | null) => void;
|
||||||
setHotCacheEnabled: (v: boolean) => void;
|
setHotCacheEnabled: (v: boolean) => void;
|
||||||
@@ -442,6 +445,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
showChangelogOnUpdate: true,
|
showChangelogOnUpdate: true,
|
||||||
lastSeenChangelogVersion: '',
|
lastSeenChangelogVersion: '',
|
||||||
seekbarStyle: 'truewave',
|
seekbarStyle: 'truewave',
|
||||||
|
reducedAnimations: false,
|
||||||
enableHiRes: false,
|
enableHiRes: false,
|
||||||
audioOutputDevice: null,
|
audioOutputDevice: null,
|
||||||
hotCacheEnabled: false,
|
hotCacheEnabled: false,
|
||||||
@@ -603,6 +607,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||||
|
|
||||||
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
|
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
|
||||||
|
setReducedAnimations: (v) => set({ reducedAnimations: v }),
|
||||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||||
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||||
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
||||||
|
|||||||
@@ -11097,7 +11097,10 @@ html[data-app-hidden="true"] *::before,
|
|||||||
html[data-app-hidden="true"] *::after,
|
html[data-app-hidden="true"] *::after,
|
||||||
html[data-psy-native-hidden="true"] *,
|
html[data-psy-native-hidden="true"] *,
|
||||||
html[data-psy-native-hidden="true"] *::before,
|
html[data-psy-native-hidden="true"] *::before,
|
||||||
html[data-psy-native-hidden="true"] *::after {
|
html[data-psy-native-hidden="true"] *::after,
|
||||||
|
html[data-app-blurred="true"] *,
|
||||||
|
html[data-app-blurred="true"] *::before,
|
||||||
|
html[data-app-blurred="true"] *::after {
|
||||||
animation-play-state: paused !important;
|
animation-play-state: paused !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__psyHidden?: boolean;
|
__psyHidden?: boolean;
|
||||||
|
__psyBlurred?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user