From c49f6af38b52928b242f26acfd389f87eca45ca3 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 13:38:33 +0200 Subject: [PATCH] perf: reduce CPU usage and unify next-track buffering settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src-tauri/src/audio.rs | 4 +- src/components/ContextMenu.tsx | 20 +- src/components/FullscreenPlayer.tsx | 38 ++- src/components/Hero.tsx | 1 - src/components/PlayerBar.tsx | 48 +++- src/components/SongInfoModal.tsx | 5 +- src/components/WaveformSeek.tsx | 72 ++++-- src/locales/de.ts | 3 + src/locales/en.ts | 3 + src/locales/fr.ts | 3 + src/locales/nb.ts | 3 + src/locales/nl.ts | 3 + src/locales/ru.ts | 3 + src/locales/zh.ts | 3 + src/pages/Favorites.tsx | 5 +- src/pages/InternetRadio.tsx | 5 +- src/pages/Playlists.tsx | 2 +- src/pages/Settings.tsx | 360 ++++++++++++++-------------- src/store/authStore.ts | 6 +- src/store/playerStore.ts | 35 ++- src/styles/components.css | 47 ++-- 21 files changed, 398 insertions(+), 271 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index f85d5593..fdaa392f 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2345,8 +2345,8 @@ fn spawn_progress_task( let mut samples_played = samples_played; loop { - // 100 ms tick — tight enough for responsive UI, low enough CPU cost. - tokio::time::sleep(Duration::from_millis(100)).await; + // 500 ms tick — frontend interpolates visually at 60 fps via rAF. + tokio::time::sleep(Duration::from_millis(500)).await; if gen_counter.load(Ordering::SeqCst) != gen { break; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index b1e4066f..f0d4b6aa 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -4,6 +4,7 @@ import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; +import { useShallow } from 'zustand/react/shallow'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; @@ -175,7 +176,24 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: export default function ContextMenu() { const { t } = useTranslation(); - const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(); + const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( + useShallow(s => ({ + contextMenu: s.contextMenu, + closeContextMenu: s.closeContextMenu, + playTrack: s.playTrack, + enqueue: s.enqueue, + queue: s.queue, + currentTrack: s.currentTrack, + removeTrack: s.removeTrack, + lastfmLovedCache: s.lastfmLovedCache, + setLastfmLovedForSong: s.setLastfmLovedForSong, + starredOverrides: s.starredOverrides, + setStarredOverride: s.setStarredOverride, + openSongInfo: s.openSongInfo, + userRatingOverrides: s.userRatingOverrides, + setUserRatingOverride: s.setUserRatingOverride, + })) + ); const auth = useAuthStore(); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 983a8958..09fa3465 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -192,34 +192,50 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { ); }); -// ─── Full-width seekbar (isolated — re-renders every tick) ──────────────────── +// ─── Full-width seekbar — imperative DOM updates, zero React re-renders on tick ─ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { - const progress = usePlayerStore(s => s.progress); - const buffered = usePlayerStore(s => s.buffered); - const currentTime = usePlayerStore(s => s.currentTime); const seek = usePlayerStore(s => s.seek); + const timeRef = useRef(null); + const playedRef = useRef(null); + const bufRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const s = usePlayerStore.getState(); + const pct = s.progress * 100; + if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime); + if (playedRef.current) playedRef.current.style.width = `${pct}%`; + if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; + if (inputRef.current) inputRef.current.value = String(s.progress); + + return usePlayerStore.subscribe(state => { + const p = state.progress * 100; + if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime); + if (playedRef.current) playedRef.current.style.width = `${p}%`; + if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`; + if (inputRef.current) inputRef.current.value = String(state.progress); + }); + }, []); const handleSeek = useCallback( (e: React.ChangeEvent) => seek(parseFloat(e.target.value)), [seek] ); - const pct = progress * 100; - const buf = Math.max(pct, buffered * 100); - return (
- {formatTime(currentTime)} + {formatTime(duration)}
-
-
+
+
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 521fd0aa..2e5959cb 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -40,7 +40,6 @@ function HeroBg({ url }: { url: string }) { style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0, - filter: layer.visible ? 'blur(0px)' : 'blur(18px)', }} aria-hidden="true" /> diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 0819579f..65df14a4 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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(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 ; +}); + 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() { ) : ( <> - {formatTime(currentTime)} +
{t('radio.live')}
@@ -251,7 +289,7 @@ export default function PlayerBar() { ) : ( <> - {formatTime(currentTime)} +
diff --git a/src/components/SongInfoModal.tsx b/src/components/SongInfoModal.tsx index 517c0ef5..3f77a353 100644 --- a/src/components/SongInfoModal.tsx +++ b/src/components/SongInfoModal.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; +import { useShallow } from 'zustand/react/shallow'; import { getSong, SubsonicSong } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; @@ -33,7 +34,9 @@ function Divider() { export default function SongInfoModal() { const { t } = useTranslation(); - const { songInfoModal, closeSongInfo } = usePlayerStore(); + const { songInfoModal, closeSongInfo } = usePlayerStore( + useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo })) + ); const [song, setSong] = useState(null); const [loading, setLoading] = useState(false); diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 1d6e2842..1bad628a 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -784,6 +784,15 @@ export function SeekbarPreview({ } // ── main component ──────────────────────────────────────────────────────────── +// +// Architecture: +// Static styles (waveform, bar, …): drawn directly in the Zustand subscription +// callback — no React re-renders, no rAF loop. 2 draws/s at the 500 ms +// Rust interval. shadowBlur + 500 canvas bars on a software-rendered +// WebKitGTK context is too expensive for a continuous 60 fps loop. +// Animated styles (pulsewave, particletrail, …): rAF loop at 60 fps, reads +// refs that the subscription keeps up-to-date. +// Drag: draws synchronously in seekToFraction for 1:1 responsiveness. interface Props { trackId: string | undefined; @@ -792,35 +801,48 @@ interface Props { export default function WaveformSeek({ trackId }: Props) { const canvasRef = useRef(null); const heightsRef = useRef(null); - const progressRef = useRef(0); - const bufferedRef = useRef(0); + const progressRef = useRef(usePlayerStore.getState().progress); + const bufferedRef = useRef(usePlayerStore.getState().buffered); const isDragging = useRef(false); const animStateRef = useRef(makeAnimState()); const [hoverPct, setHoverPct] = useState(null); - const progress = usePlayerStore(s => s.progress); - const buffered = usePlayerStore(s => s.buffered); const seek = usePlayerStore(s => s.seek); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); - progressRef.current = progress; - bufferedRef.current = buffered; + // Ref so the subscription callback (closed over at mount) can read the + // current style without stale-closure issues. + const styleRef = useRef(seekbarStyle); + styleRef.current = seekbarStyle; useEffect(() => { heightsRef.current = trackId ? makeHeights(trackId) : null; }, [trackId]); - // Static styles: redraw on progress / buffered / track changes + // Imperative subscription — no React re-renders from progress changes. + // Static styles draw here; animated styles only update refs. + useEffect(() => { + return usePlayerStore.subscribe((state, prev) => { + if (state.progress === prev.progress && state.buffered === prev.buffered) return; + progressRef.current = state.progress; + bufferedRef.current = state.buffered; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered); + } + }); + }, []); + + // Initial draw for static styles when style or track changes. useEffect(() => { if (ANIMATED_STYLES.has(seekbarStyle)) return; - if (canvasRef.current) { - drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered); - } - }, [progress, buffered, trackId, seekbarStyle]); + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); + }, [seekbarStyle, trackId]); - // Animated styles: rAF loop + // rAF loop — animated styles only. useEffect(() => { if (!ANIMATED_STYLES.has(seekbarStyle)) return; const canvas = canvasRef.current; @@ -829,21 +851,14 @@ export default function WaveformSeek({ trackId }: Props) { let rafId: number; const tick = () => { animStateRef.current.time += 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); return () => cancelAnimationFrame(rafId); }, [seekbarStyle]); - // Resize observer + // Resize observer. useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -859,12 +874,23 @@ export default function WaveformSeek({ trackId }: Props) { const seekRef = useRef(seek); seekRef.current = seek; + // Seek to a 0–1 fraction: draw immediately for 1:1 responsiveness, then + // let the store + Rust catch up asynchronously. + const seekToFraction = (fraction: number) => { + progressRef.current = fraction; + const canvas = canvasRef.current; + if (canvas && !ANIMATED_STYLES.has(styleRef.current)) { + drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current); + } + seekRef.current(fraction); + }; + useEffect(() => { const seekFromX = (clientX: number) => { const canvas = canvasRef.current; if (!canvas || !trackIdRef.current) return; const rect = canvas.getBoundingClientRect(); - seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))); + seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))); }; const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); }; const onUp = () => { isDragging.current = false; }; @@ -892,7 +918,7 @@ export default function WaveformSeek({ trackId }: Props) { onMouseDown={e => { isDragging.current = true; const rect = e.currentTarget.getBoundingClientRect(); - seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); + seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); }} onMouseMove={e => { if (!trackId) return; diff --git a/src/locales/de.ts b/src/locales/de.ts index 68212884..38270e47 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -571,6 +571,9 @@ export const deTranslation = { gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden', preloadMode: 'Nächsten Track vorpuffern', preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll', + nextTrackBufferingTitle: 'Nächster Track – Pufferung', + preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck – nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.', + preloadOff: 'Aus', preloadBalanced: 'Ausgewogen (30 s vor Ende)', preloadEarly: 'Früh (nach 5 s Wiedergabe)', preloadCustom: 'Benutzerdefiniert', diff --git a/src/locales/en.ts b/src/locales/en.ts index 58d5c025..fd6fc41b 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -572,6 +572,9 @@ export const enTranslation = { gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs', preloadMode: 'Preload Next Track', preloadModeDesc: 'When to start buffering the next track in the queue', + nextTrackBufferingTitle: 'Next Track Buffering', + preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.', + preloadOff: 'Off', preloadBalanced: 'Balanced (30 s before end)', preloadEarly: 'Early (after 5 s of playback)', preloadCustom: 'Custom', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index d5366264..8d535f0c 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -569,6 +569,9 @@ export const frTranslation = { gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux', preloadMode: 'Précharger la piste suivante', preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante', + nextTrackBufferingTitle: 'Piste suivante – mise en mémoire tampon', + preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.", + preloadOff: 'Désactivé', preloadBalanced: 'Équilibré (30 s avant la fin)', preloadEarly: 'Tôt (après 5 s de lecture)', preloadCustom: 'Personnalisé', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 50934d64..937711c7 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -571,6 +571,9 @@ export const nbTranslation = { experimental: 'Eksperimentell', preloadMode: 'Forhåndslast neste spor', preloadModeDesc: 'Når buffering av neste spor i køen skal starte', + nextTrackBufferingTitle: 'Neste spor – bufring', + preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål – bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.', + preloadOff: 'Av', preloadBalanced: 'Balansert (30 s før slutt)', preloadEarly: 'Tidlig (etter 5 s avspilling)', preloadCustom: 'Egendefinert', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 7866cd2f..0c97c09c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -569,6 +569,9 @@ export const nlTranslation = { gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren', preloadMode: 'Volgend nummer vooraf laden', preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen', + nextTrackBufferingTitle: 'Volgend nummer – buffering', + preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.', + preloadOff: 'Uit', preloadBalanced: 'Gebalanceerd (30 s voor einde)', preloadEarly: 'Vroeg (na 5 s afspelen)', preloadCustom: 'Aangepast', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 33e2c152..09675abc 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -597,6 +597,9 @@ export const ruTranslation = { gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины', preloadMode: 'Предзагрузка следующего трека', preloadModeDesc: 'Когда начинать буферизацию следующего в очереди', + nextTrackBufferingTitle: 'Буферизация следующего трека', + preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.', + preloadOff: 'Выкл.', preloadBalanced: 'Умеренно (за 30 с до конца)', preloadEarly: 'Рано (через 5 с после старта)', preloadCustom: 'Свой интервал', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 6665ede4..904452b6 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -565,6 +565,9 @@ export const zhTranslation = { gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙', preloadMode: '预加载下一曲目', preloadModeDesc: '何时开始缓冲队列中的下一曲目', + nextTrackBufferingTitle: '下一曲目缓冲', + preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。', + preloadOff: '关闭', preloadBalanced: '均衡(结束前30秒)', preloadEarly: '提前(播放5秒后)', preloadCustom: '自定义', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index c1b971ee..b333592e 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -43,7 +43,10 @@ export default function Favorites() { const [ratings, setRatings] = useState>({}); - const { playTrack, enqueue, playRadio, stop } = usePlayerStore(); + const playTrack = usePlayerStore(s => s.playTrack); + const enqueue = usePlayerStore(s => s.enqueue); + const playRadio = usePlayerStore(s => s.playRadio); + const stop = usePlayerStore(s => s.stop); const currentTrack = usePlayerStore(s => s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); const isPlaying = usePlayerStore(s => s.isPlaying); diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx index a45c4c64..33b313a9 100644 --- a/src/pages/InternetRadio.tsx +++ b/src/pages/InternetRadio.tsx @@ -19,7 +19,10 @@ import { showToast } from '../utils/toast'; export default function InternetRadio() { const { t } = useTranslation(); - const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore(); + const playRadio = usePlayerStore(s => s.playRadio); + const stop = usePlayerStore(s => s.stop); + const currentRadio = usePlayerStore(s => s.currentRadio); + const isPlaying = usePlayerStore(s => s.isPlaying); const [stations, setStations] = useState([]); const [loading, setLoading] = useState(true); diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 192e020e..dc800877 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -15,7 +15,7 @@ function formatDuration(seconds: number): string { export default function Playlists() { const { t } = useTranslation(); const navigate = useNavigate(); - const { playTrack } = usePlayerStore(); + const playTrack = usePlayerStore(s => s.playTrack); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const removeId = usePlaylistStore((s) => s.removeId); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7d36f48b..b6c6787b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -249,6 +249,10 @@ export default function Settings() { }, [auth.lastfmSessionKey, auth.lastfmUsername]); useEffect(() => { + if (activeTab === 'audio') { + invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); + return; + } if (activeTab !== 'storage') return; getImageCacheSize().then(setImageCacheBytes); invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); @@ -524,8 +528,19 @@ export default function Settings() {
+
+ -
+ {/* Next Track Buffering */} +
+
+ +

{t('settings.nextTrackBufferingTitle')}

+
+
+
+ {t('settings.preloadHotCacheMutualExclusive')} +
{/* Preload mode */}
@@ -533,31 +548,170 @@ export default function Settings() {
{t('settings.preloadMode')}
{t('settings.preloadModeDesc')}
-
-
- {(['balanced', 'early', 'custom'] as const).map(mode => ( - - ))} -
- {auth.preloadMode === 'custom' && ( -
+ +
+ {auth.preloadMode !== 'off' && ( + <> +
+ {(['balanced', 'early', 'custom'] as const).map(mode => ( + + ))} +
+ {auth.preloadMode === 'custom' && ( +
+ auth.setPreloadCustomSeconds(parseInt(e.target.value))} + style={{ width: 120 }} + /> + + {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} + +
+ )} + + )} + +
+ + {/* Hot Cache */} +
+
+
+ {t('settings.hotCacheTitle')} + + {t('settings.hotCacheAlphaBadge')} + +
+
{t('settings.hotCacheDisclaimer')}
+
+ +
+ + {auth.hotCacheEnabled && ( +
+
+ + {auth.hotCacheDownloadDir && ( + + )} + +
+ {auth.hotCacheDownloadDir && ( +
+ {t('settings.hotCacheDirHint')} +
+ )} + +
+ +
+
+ {t('settings.cacheUsedHot')} + {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} +
+
+ {t('settings.hotCacheTrackCount')} + {hotCacheTrackCount} +
+
+ +
+
{t('settings.hotCacheMaxMb')}
+
+ auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" /> + {snapHotCacheMb(auth.hotCacheMaxMb)} MB +
+
+
+
{t('settings.hotCacheDebounce')}
+
+ auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" /> + + {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 + ? t('settings.hotCacheDebounceImmediate') + : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} + +
+
+ +
+
)} @@ -1002,164 +1156,6 @@ export default function Settings() {
-
-
- -

- {t('settings.hotCacheTitle')} - - {t('settings.hotCacheAlphaBadge')} - -

-
-
-
- {t('settings.hotCacheDisclaimer')} -
- -
-
-
{t('settings.hotCacheEnabled')}
-
- -
- - {auth.hotCacheEnabled && ( -
-
- - {auth.hotCacheDownloadDir && ( - - )} - -
- {auth.hotCacheDownloadDir && ( -
- {t('settings.hotCacheDirHint')} -
- )} - -
- -
-
- {t('settings.cacheUsedHot')} - {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} -
-
- {t('settings.hotCacheTrackCount')} - {hotCacheTrackCount} -
-
- -
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} - style={{ width: 140 }} - id="hot-cache-max-mb-slider" - /> - - {snapHotCacheMb(auth.hotCacheMaxMb)} MB - -
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} - style={{ width: 140 }} - id="hot-cache-debounce-slider" - /> - - {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 - ? t('settings.hotCacheDebounceImmediate') - : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} - -
-
- -
- -
- )} -
-
- {/* ZIP Export & Archiving */}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 7834660f..952a108b 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -37,7 +37,7 @@ interface AuthState { crossfadeEnabled: boolean; crossfadeSecs: number; gaplessEnabled: boolean; - preloadMode: 'balanced' | 'early' | 'custom'; + preloadMode: 'off' | 'balanced' | 'early' | 'custom'; preloadCustomSeconds: number; infiniteQueueEnabled: boolean; showArtistImages: boolean; @@ -133,7 +133,7 @@ interface AuthState { setCrossfadeEnabled: (v: boolean) => void; setCrossfadeSecs: (v: number) => void; setGaplessEnabled: (v: boolean) => void; - setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void; + setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void; setPreloadCustomSeconds: (v: number) => void; setInfiniteQueueEnabled: (v: boolean) => void; setShowArtistImages: (v: boolean) => void; @@ -315,7 +315,7 @@ export const useAuthStore = create()( setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }), - setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }), + setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }), setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }), setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), setShowArtistImages: (v) => set({ showArtistImages: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 8ed4e0a4..6ea036ea 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -331,11 +331,13 @@ function handleAudioProgress(current_time: number, duration: number) { // Pre-buffer / pre-chain next track based on preload mode. const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState(); const remaining = dur - current_time; - const shouldPreload = preloadMode === 'early' - ? current_time >= 5 - : preloadMode === 'custom' - ? remaining < preloadCustomSeconds && remaining > 0 - : remaining < 30 && remaining > 0; // balanced (default) + const shouldPreload = preloadMode !== 'off' && ( + preloadMode === 'early' + ? current_time >= 5 + : preloadMode === 'custom' + ? remaining < preloadCustomSeconds && remaining > 0 + : remaining < 30 && remaining > 0 // balanced (default) + ); if (shouldPreload) { const { queue, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; @@ -483,11 +485,28 @@ function handleAudioError(message: string) { * set of listeners before creating the second, avoiding duplicate handlers. */ export function initAudioListeners(): () => void { + // Dev-only: warn when audio:progress events arrive faster than 10/s. + // This would indicate the Rust emit interval was accidentally lowered. + let _devEventCount = 0; + let _devWindowStart = 0; + const pending = [ listen('audio:playing', ({ payload }) => handleAudioPlaying(payload)), - listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => - handleAudioProgress(payload.current_time, payload.duration) - ), + listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => { + if (import.meta.env.DEV) { + _devEventCount++; + const now = Date.now(); + if (_devWindowStart === 0) _devWindowStart = now; + if (now - _devWindowStart >= 1000) { + if (_devEventCount > 10) { + console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`); + } + _devEventCount = 0; + _devWindowStart = now; + } + } + handleAudioProgress(payload.current_time, payload.duration); + }), listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), diff --git a/src/styles/components.css b/src/styles/components.css index b6f9fd21..f211190d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -21,17 +21,7 @@ inset: 0; background-size: cover; background-position: center; - transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease; - transform: scale(1.05); - filter: blur(12px); -} - -.hero:hover .hero-bg { - filter: blur(8px); -} - -.hero:hover .hero-bg { - transform: scale(1); + transition: opacity 0.8s ease; } .hero-dots { @@ -909,11 +899,8 @@ inset: 0; background-size: cover; background-position: center; - filter: blur(20px) brightness(0.6); - transform: scale(1.1); - /* Hide blur edges */ z-index: 0; - opacity: 0.8; + opacity: 0.35; transition: opacity var(--transition-slow); } @@ -3014,23 +3001,22 @@ to { transform: translateY(0); opacity: 1; } } -/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */ @keyframes mesh-aura-a { - 0% { transform: translate3d(0, 0, 0); } - 33% { transform: translate3d(5%, -6%, 0); } - 66% { transform: translate3d(-3%, 2%, 0); } - 100% { transform: translate3d(0, 0, 0); } + 0% { transform: translate(0, 0 ); } + 33% { transform: translate(5%, -6% ); } + 66% { transform: translate(-3%, 2% ); } + 100% { transform: translate(0, 0 ); } } @keyframes mesh-aura-b { - 0% { transform: translate3d(0, 0, 0); } - 50% { transform: translate3d(-6%, 5%, 0); } - 100% { transform: translate3d(0, 0, 0); } + 0% { transform: translate(0, 0 ); } + 50% { transform: translate(-6%, 5% ); } + 100% { transform: translate(0, 0 ); } } @keyframes portrait-drift { - 0%, 100% { transform: translate3d(0, 0, 0); } - 50% { transform: translate3d(0, -10px, 0); } + 0%, 100% { transform: translateY(0 ); } + 50% { transform: translateY(-10px); } } .fs-mesh-bg { @@ -3042,12 +3028,11 @@ pointer-events: none; } -/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */ +/* Blobs are real divs — no will-change/filter to avoid software compositing layers */ .fs-mesh-blob { position: absolute; border-radius: 50%; pointer-events: none; - will-change: transform; } .fs-mesh-blob-a { @@ -3058,8 +3043,8 @@ bottom: -35%; background: radial-gradient(ellipse, color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%, - transparent 65%); - filter: blur(55px); + color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 96%) 45%, + transparent 70%); animation: mesh-aura-a 26s ease-in-out infinite; animation-delay: 350ms; transition: background 400ms ease-in-out; @@ -3072,8 +3057,8 @@ top: -25%; background: radial-gradient(ellipse, color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%, - transparent 65%); - filter: blur(65px); + color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 98%) 45%, + transparent 70%); animation: mesh-aura-b 20s ease-in-out infinite; animation-delay: 350ms; transition: background 400ms ease-in-out;