From 829936a78dc8fceee69414fb69930524a786dbbb Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Wed, 8 Apr 2026 01:39:08 +0200 Subject: [PATCH] feat(seekbar): configurable seekbar styles with animated preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add five seekbar styles selectable in Settings → Appearance: Waveform, Line & Dot, Bar, Thick Bar, and Segmented. Each option shows an animated canvas preview using requestAnimationFrame. Style is persisted in authStore (seekbarStyle, default: waveform). Translations added for all 7 languages. Co-Authored-By: Claude Sonnet 4.6 --- src/components/WaveformSeek.tsx | 348 +++++++++++++++++++++++++++----- src/locales/de.ts | 7 + src/locales/en.ts | 7 + src/locales/fr.ts | 7 + src/locales/nb.ts | 7 + src/locales/nl.ts | 7 + src/locales/ru.ts | 7 + src/locales/zh.ts | 7 + src/pages/Settings.tsx | 26 ++- src/store/authStore.ts | 7 + 10 files changed, 377 insertions(+), 53 deletions(-) diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 56ca76ce..0f8d1cf9 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; +import { useAuthStore, type SeekbarStyle } from '../store/authStore'; function fmt(s: number): string { if (!s || isNaN(s)) return '0:00'; @@ -7,6 +8,43 @@ function fmt(s: number): string { } const BAR_COUNT = 500; +const SEG_COUNT = 60; + +// ── color helper ────────────────────────────────────────────────────────────── + +function getColors() { + const s = getComputedStyle(document.documentElement); + return { + played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7', + buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086', + unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244', + }; +} + +// ── canvas setup ────────────────────────────────────────────────────────────── + +function setupCanvas( + canvas: HTMLCanvasElement, +): { ctx: CanvasRenderingContext2D; w: number; h: number } | null { + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const rect = canvas.getBoundingClientRect(); + const w = rect.width || canvas.clientWidth; + const h = rect.height || canvas.clientHeight; + if (w === 0 || h === 0) return null; + const dpr = window.devicePixelRatio || 1; + const pw = Math.round(w * dpr); + const ph = Math.round(h * dpr); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + return { ctx, w, h }; +} + +// ── waveform heights ────────────────────────────────────────────────────────── function hashStr(str: string): number { let h = 0x811c9dc5; @@ -17,108 +55,313 @@ function hashStr(str: string): number { return h; } -function makeHeights(trackId: string): Float32Array { +export function makeHeights(trackId: string): Float32Array { let s = hashStr(trackId); const h = new Float32Array(BAR_COUNT); for (let i = 0; i < BAR_COUNT; i++) { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; h[i] = s / 0xffffffff; } - // Smooth for an organic look for (let pass = 0; pass < 5; pass++) { for (let i = 1; i < BAR_COUNT - 1; i++) { h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25; } } - // Normalize to [0.12, 1.0] let max = 0; for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i]; - if (max > 0) { - for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88; - } + if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88; return h; } +// ── draw functions ──────────────────────────────────────────────────────────── + function drawWaveform( canvas: HTMLCanvasElement, heights: Float32Array | null, progress: number, buffered: number, ) { - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const rect = canvas.getBoundingClientRect(); - const w = rect.width || canvas.clientWidth; - const h = rect.height || canvas.clientHeight; - if (w === 0 || h === 0) return; - - const dpr = window.devicePixelRatio || 1; - const pw = Math.round(w * dpr); - const ph = Math.round(h * dpr); - if (canvas.width !== pw || canvas.height !== ph) { - canvas.width = pw; - canvas.height = ph; - } - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - - const style = getComputedStyle(document.documentElement); - const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7'; - const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086'; - const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244'; + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); if (!heights) { ctx.globalAlpha = 0.3; - ctx.fillStyle = colorUnplayed; + ctx.fillStyle = unplayed; ctx.fillRect(0, (h - 2) / 2, w, 2); ctx.globalAlpha = 1; return; } - // Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps. const x1Of = (i: number) => (i / BAR_COUNT) * w; const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; - // Pass 1 — unplayed (dim) ctx.globalAlpha = 0.28; - ctx.fillStyle = colorUnplayed; + ctx.fillStyle = unplayed; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT < buffered) continue; - const barH = Math.max(1, heights[i] * h); + const bh = Math.max(1, heights[i] * h); const x = x1Of(i); - ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } - // Pass 2 — buffered (slightly brighter) ctx.globalAlpha = 0.45; - ctx.fillStyle = colorBuffered; + ctx.fillStyle = buffCol; for (let i = 0; i < BAR_COUNT; i++) { const frac = i / BAR_COUNT; if (frac < progress || frac >= buffered) continue; - const barH = Math.max(1, heights[i] * h); + const bh = Math.max(1, heights[i] * h); const x = x1Of(i); - ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } - // Pass 3 — played (accent color + glow) if (progress > 0) { ctx.globalAlpha = 1; - ctx.fillStyle = colorAccent; - ctx.shadowColor = colorAccent; + ctx.fillStyle = played; + ctx.shadowColor = played; ctx.shadowBlur = 5; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT >= progress) break; - const barH = Math.max(1, heights[i] * h); + const bh = Math.max(1, heights[i] * h); const x = x1Of(i); - ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } ctx.shadowBlur = 0; } - ctx.globalAlpha = 1; } +function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const cy = h / 2; + const lh = 2; + const dotR = 5; + + ctx.globalAlpha = 0.35; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - lh / 2, w, lh); + + if (buffered > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - lh / 2, buffered * w, lh); + } + + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.fillRect(0, cy - lh / 2, progress * w, lh); + + const dx = Math.max(dotR, Math.min(w - dotR, progress * w)); + ctx.shadowColor = played; + ctx.shadowBlur = 7; + ctx.beginPath(); + ctx.arc(dx, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = played; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; +} + +function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const bh = 4; + const rad = bh / 2; + const y = (h - bh) / 2; + + ctx.globalAlpha = 0.3; + ctx.fillStyle = unplayed; + ctx.beginPath(); + ctx.roundRect(0, y, w, bh, rad); + ctx.fill(); + + if (buffered > 0) { + ctx.globalAlpha = 0.5; + ctx.fillStyle = buffCol; + ctx.beginPath(); + ctx.roundRect(0, y, buffered * w, bh, rad); + ctx.fill(); + } + + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + ctx.shadowBlur = 5; + ctx.beginPath(); + ctx.roundRect(0, y, progress * w, bh, rad); + ctx.fill(); + ctx.shadowBlur = 0; + } + ctx.globalAlpha = 1; +} + +function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const bh = Math.min(14, h); + const rad = bh / 2; + const y = (h - bh) / 2; + + ctx.globalAlpha = 0.25; + ctx.fillStyle = unplayed; + ctx.beginPath(); + ctx.roundRect(0, y, w, bh, rad); + ctx.fill(); + + if (buffered > 0) { + ctx.globalAlpha = 0.45; + ctx.fillStyle = buffCol; + ctx.beginPath(); + ctx.roundRect(0, y, buffered * w, bh, rad); + ctx.fill(); + } + + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + ctx.shadowBlur = 10; + ctx.beginPath(); + ctx.roundRect(0, y, progress * w, bh, rad); + ctx.fill(); + ctx.shadowBlur = 0; + } + ctx.globalAlpha = 1; +} + +function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const gap = 2; + const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT; + const segH = h * 0.65; + const y = (h - segH) / 2; + const playedIdx = Math.floor(progress * SEG_COUNT); + + for (let i = 0; i < SEG_COUNT; i++) { + const frac = i / SEG_COUNT; + const x = i * (segW + gap); + ctx.shadowBlur = 0; + if (frac < progress) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + if (i === playedIdx - 1) { + ctx.shadowColor = played; + ctx.shadowBlur = 5; + } + } else if (frac < buffered) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + } else { + ctx.globalAlpha = 0.28; + ctx.fillStyle = unplayed; + } + ctx.beginPath(); + ctx.roundRect(x, y, Math.max(1, segW), segH, 1); + ctx.fill(); + } + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; +} + +// ── dispatcher ──────────────────────────────────────────────────────────────── + +export function drawSeekbar( + canvas: HTMLCanvasElement, + style: SeekbarStyle, + heights: Float32Array | null, + progress: number, + buffered: number, +) { + switch (style) { + case 'waveform': drawWaveform(canvas, heights, progress, buffered); break; + case 'linedot': drawLineDot(canvas, progress, buffered); break; + case 'bar': drawBar(canvas, progress, buffered); break; + case 'thick': drawThick(canvas, progress, buffered); break; + case 'segmented': drawSegmented(canvas, progress, buffered); break; + } +} + +// ── SeekbarPreview (animated, for Settings) ─────────────────────────────────── + +export function SeekbarPreview({ + style, + label, + selected, + onClick, +}: { + style: SeekbarStyle; + label: string; + selected: boolean; + onClick: () => void; +}) { + const canvasRef = useRef(null); + const rafRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null; + let t = 0; + const tick = () => { + t += 0.012; + const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t)); + const buffered = Math.min(1, progress + 0.18); + drawSeekbar(canvas, style, heights, progress, buffered); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; + }, [style]); + + return ( + + ); +} + +// ── main component ──────────────────────────────────────────────────────────── + interface Props { trackId: string | undefined; } @@ -132,10 +375,11 @@ export default function WaveformSeek({ trackId }: Props) { 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 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; @@ -146,19 +390,19 @@ export default function WaveformSeek({ trackId }: Props) { useEffect(() => { if (canvasRef.current) { - drawWaveform(canvasRef.current, heightsRef.current, progress, buffered); + drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered); } - }, [progress, buffered, trackId]); + }, [progress, buffered, trackId, seekbarStyle]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ro = new ResizeObserver(() => { - drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current); + drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); }); ro.observe(canvas); return () => ro.disconnect(); - }, []); + }, [seekbarStyle]); const trackIdRef = useRef(trackId); trackIdRef.current = trackId; diff --git a/src/locales/de.ts b/src/locales/de.ts index 28ee7194..e26b9636 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -543,6 +543,13 @@ export const deTranslation = { infiniteQueue: 'Endlose Warteschlange', infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird', experimental: 'Experimentell', + seekbarStyle: 'Seekbar-Stil', + seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen', + seekbarWaveform: 'Wellenform', + seekbarLinedot: 'Linie & Punkt', + seekbarBar: 'Balken', + seekbarThick: 'Dicker Balken', + seekbarSegmented: 'Segmentiert', }, changelog: { modalTitle: 'Was ist neu', diff --git a/src/locales/en.ts b/src/locales/en.ts index 220d11e4..376fe83d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -544,6 +544,13 @@ export const enTranslation = { infiniteQueue: 'Infinite Queue', infiniteQueueDesc: 'Automatically append random tracks when the queue runs out', experimental: 'Experimental', + seekbarStyle: 'Seekbar Style', + seekbarStyleDesc: 'Choose the look of the player seek bar', + seekbarWaveform: 'Waveform', + seekbarLinedot: 'Line & Dot', + seekbarBar: 'Bar', + seekbarThick: 'Thick Bar', + seekbarSegmented: 'Segmented', }, changelog: { modalTitle: "What's New", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index efab16e6..b0cc9f02 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -541,6 +541,13 @@ export const frTranslation = { infiniteQueue: 'File infinie', infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée', experimental: 'Expérimental', + seekbarStyle: 'Style de la barre de lecture', + seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression', + seekbarWaveform: 'Forme d\'onde', + seekbarLinedot: 'Ligne & point', + seekbarBar: 'Barre', + seekbarThick: 'Barre épaisse', + seekbarSegmented: 'Segmentée', }, changelog: { modalTitle: 'Quoi de neuf', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 11c2ce01..a7f8f789 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -540,6 +540,13 @@ export const nbTranslation = { preloadEarly: 'Tidlig (etter 5 s avspilling)', preloadCustom: 'Egendefinert', preloadCustomSeconds: 'Sekunder før slutt: {{n}}', + seekbarStyle: 'Søkefelt-stil', + seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet', + seekbarWaveform: 'Bølgeform', + seekbarLinedot: 'Linje & punkt', + seekbarBar: 'Linje', + seekbarThick: 'Tykk linje', + seekbarSegmented: 'Segmentert', }, changelog: { modalTitle: "Nyheter", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 099a8e43..f1a8d98a 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -541,6 +541,13 @@ export const nlTranslation = { infiniteQueue: 'Oneindige wachtrij', infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt', experimental: 'Experimenteel', + seekbarStyle: 'Zoekbalkstijl', + seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk', + seekbarWaveform: 'Golfvorm', + seekbarLinedot: 'Lijn & punt', + seekbarBar: 'Balk', + seekbarThick: 'Dikke balk', + seekbarSegmented: 'Gesegmenteerd', }, changelog: { modalTitle: 'Wat is nieuw', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index ebf6ded5..f5ff9913 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -564,6 +564,13 @@ export const ruTranslation = { infiniteQueue: 'Бесконечная очередь', infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится', experimental: 'Экспериментально', + seekbarStyle: 'Стиль прогресс-бара', + seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения', + seekbarWaveform: 'Форма волны', + seekbarLinedot: 'Линия и точка', + seekbarBar: 'Полоса', + seekbarThick: 'Толстая полоса', + seekbarSegmented: 'Сегменты', }, changelog: { modalTitle: 'Что нового', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 3c8e6785..fe48cc12 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -537,6 +537,13 @@ export const zhTranslation = { infiniteQueue: '无限队列', infiniteQueueDesc: '队列播完时自动追加随机曲目', experimental: '实验性', + seekbarStyle: '进度条样式', + seekbarStyleDesc: '选择播放进度条的外观', + seekbarWaveform: '波形', + seekbarLinedot: '线条与点', + seekbarBar: '条形', + seekbarThick: '粗条形', + seekbarSegmented: '分段式', }, changelog: { modalTitle: '新功能', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d6fc9bad..6cc91176 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -18,7 +18,8 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import ThemePicker from '../components/ThemePicker'; -import { useAuthStore, ServerProfile } from '../store/authStore'; +import { useAuthStore, ServerProfile, type SeekbarStyle } from '../store/authStore'; +import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; @@ -1147,6 +1148,29 @@ export default function Settings() { +
+
+ +

{t('settings.seekbarStyle')}

+
+
+
+ {t('settings.seekbarStyleDesc')} +
+
+ {(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => ( + auth.setSeekbarStyle(style)} + /> + ))} +
+
+
+ )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index b1d9a9d8..a7f96fe4 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -11,6 +11,8 @@ export interface ServerProfile { password: string; } +export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented'; + interface AuthState { // Multi-server servers: ServerProfile[]; @@ -49,6 +51,8 @@ interface AuthState { showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; + seekbarStyle: SeekbarStyle; + /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ enableHiRes: boolean; @@ -112,6 +116,7 @@ interface AuthState { setShowFullscreenLyrics: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; + setSeekbarStyle: (v: SeekbarStyle) => void; setEnableHiRes: (v: boolean) => void; setHotCacheEnabled: (v: boolean) => void; setHotCacheMaxMb: (v: number) => void; @@ -164,6 +169,7 @@ export const useAuthStore = create()( showFullscreenLyrics: true, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', + seekbarStyle: 'waveform', enableHiRes: false, hotCacheEnabled: false, hotCacheMaxMb: 256, @@ -250,6 +256,7 @@ export const useAuthStore = create()( setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), + setSeekbarStyle: (v) => set({ seekbarStyle: v }), setEnableHiRes: (v) => set({ enableHiRes: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),