perf: reduce CPU usage and unify next-track buffering settings

- Throttle audio:progress from 100ms to 500ms; WaveformSeek and
  FsSeekbar use imperative DOM updates instead of React re-renders
- Fix all usePlayerStore() calls without selectors across pages and
  components (useShallow / individual selectors throughout)
- Remove filter:blur and transform:scale from Hero, AlbumDetail and
  FullscreenPlayer backgrounds — eliminated expensive software
  compositing layers on WebKitGTK
- Replace translate3d with 2D translate in FS mesh and portrait
  animations; remove will-change:transform from mesh blobs
- Move Hot Cache section from Storage tab to Audio tab; group Preload
  and Hot Cache under a shared 'Next Track Buffering' section with a
  mutual-exclusivity note and auto-disable logic
- Add 'off' toggle to Preload (replaces Off button with toggle switch);
  enabling either method now automatically disables the other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-10 13:38:33 +02:00
parent c9dfbcc19f
commit c49f6af38b
21 changed files with 398 additions and 271 deletions
+43 -5
View File
@@ -1,10 +1,11 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, MicVocal, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
@@ -25,6 +26,21 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Renders the playback clock without ever causing PlayerBar to re-render.
// Updates the DOM directly via an imperative store subscription.
const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (spanRef.current) {
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
}
return usePlayerStore.subscribe(state => {
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
});
}, []);
return <span className={className} ref={spanRef} />;
});
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -32,15 +48,37 @@ export default function PlayerBar() {
const [showVolPct, setShowVolPct] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
const {
currentTrack, currentRadio, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
} = usePlayerStore();
} = usePlayerStore(useShallow(s => ({
currentTrack: s.currentTrack,
currentRadio: s.currentRadio,
isPlaying: s.isPlaying,
volume: s.volume,
togglePlay: s.togglePlay,
next: s.next,
previous: s.previous,
setVolume: s.setVolume,
stop: s.stop,
toggleRepeat: s.toggleRepeat,
repeatMode: s.repeatMode,
toggleFullscreen: s.toggleFullscreen,
lastfmLoved: s.lastfmLoved,
toggleLastfmLove: s.toggleLastfmLove,
isQueueVisible: s.isQueueVisible,
toggleQueue: s.toggleQueue,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
})));
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
@@ -241,7 +279,7 @@ export default function PlayerBar() {
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
@@ -251,7 +289,7 @@ export default function PlayerBar() {
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>