mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -2345,8 +2345,8 @@ fn spawn_progress_task(
|
|||||||
let mut samples_played = samples_played;
|
let mut samples_played = samples_played;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import LastfmIcon from './LastfmIcon';
|
|||||||
import StarRating from './StarRating';
|
import StarRating from './StarRating';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
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 { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -175,7 +176,24 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
|||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
const { t } = useTranslation();
|
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 auth = useAuthStore();
|
||||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -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 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 seek = usePlayerStore(s => s.seek);
|
||||||
|
const timeRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const playedRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bufRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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(
|
const handleSeek = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||||
[seek]
|
[seek]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pct = progress * 100;
|
|
||||||
const buf = Math.max(pct, buffered * 100);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fs-seekbar-wrap">
|
<div className="fs-seekbar-wrap">
|
||||||
<div className="fs-seekbar-times">
|
<div className="fs-seekbar-times">
|
||||||
<span>{formatTime(currentTime)}</span>
|
<span ref={timeRef} />
|
||||||
<span>{formatTime(duration)}</span>
|
<span>{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="fs-seekbar">
|
<div className="fs-seekbar">
|
||||||
<div className="fs-seekbar-bg" />
|
<div className="fs-seekbar-bg" />
|
||||||
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
|
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||||
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
|
<div className="fs-seekbar-played" ref={playedRef} />
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="range" min={0} max={1} step={0.001}
|
type="range" min={0} max={1} step={0.001}
|
||||||
value={progress}
|
defaultValue={0}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
aria-label="seek"
|
aria-label="seek"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ function HeroBg({ url }: { url: string }) {
|
|||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${layer.url})`,
|
backgroundImage: `url(${layer.url})`,
|
||||||
opacity: layer.visible ? 1 : 0,
|
opacity: layer.visible ? 1 : 0,
|
||||||
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
|
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, MicVocal, Cast
|
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, MicVocal, Cast
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
@@ -25,6 +26,21 @@ function formatTime(seconds: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
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() {
|
export default function PlayerBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,15 +48,37 @@ export default function PlayerBar() {
|
|||||||
const [showVolPct, setShowVolPct] = useState(false);
|
const [showVolPct, setShowVolPct] = useState(false);
|
||||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||||
const activeTab = useLyricsStore(s => s.activeTab);
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
|
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
|
||||||
const {
|
const {
|
||||||
currentTrack, currentRadio, isPlaying, currentTime, volume,
|
currentTrack, currentRadio, isPlaying, volume,
|
||||||
togglePlay, next, previous, setVolume,
|
togglePlay, next, previous, setVolume,
|
||||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||||
lastfmLoved, toggleLastfmLove,
|
lastfmLoved, toggleLastfmLove,
|
||||||
isQueueVisible, toggleQueue,
|
isQueueVisible, toggleQueue,
|
||||||
starredOverrides, setStarredOverride,
|
starredOverrides, setStarredOverride,
|
||||||
userRatingOverrides, setUserRatingOverride,
|
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 { lastfmSessionKey } = useAuthStore();
|
||||||
|
|
||||||
const isRadio = !!currentRadio;
|
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' }}>
|
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||||
</div>
|
</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">
|
<div className="player-waveform-wrap">
|
||||||
<WaveformSeek trackId={currentTrack?.id} />
|
<WaveformSeek trackId={currentTrack?.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ function Divider() {
|
|||||||
|
|
||||||
export default function SongInfoModal() {
|
export default function SongInfoModal() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { songInfoModal, closeSongInfo } = usePlayerStore();
|
const { songInfoModal, closeSongInfo } = usePlayerStore(
|
||||||
|
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
|
||||||
|
);
|
||||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -784,6 +784,15 @@ export function SeekbarPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── main component ────────────────────────────────────────────────────────────
|
// ── 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 {
|
interface Props {
|
||||||
trackId: string | undefined;
|
trackId: string | undefined;
|
||||||
@@ -792,35 +801,48 @@ interface Props {
|
|||||||
export default function WaveformSeek({ trackId }: Props) {
|
export default function WaveformSeek({ trackId }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const heightsRef = useRef<Float32Array | null>(null);
|
const heightsRef = useRef<Float32Array | null>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||||
const bufferedRef = useRef(0);
|
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
const animStateRef = useRef<AnimState>(makeAnimState());
|
const animStateRef = useRef<AnimState>(makeAnimState());
|
||||||
|
|
||||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||||
|
|
||||||
const progress = usePlayerStore(s => s.progress);
|
|
||||||
const buffered = usePlayerStore(s => s.buffered);
|
|
||||||
const seek = usePlayerStore(s => s.seek);
|
const seek = usePlayerStore(s => s.seek);
|
||||||
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);
|
||||||
|
|
||||||
progressRef.current = progress;
|
// Ref so the subscription callback (closed over at mount) can read the
|
||||||
bufferedRef.current = buffered;
|
// current style without stale-closure issues.
|
||||||
|
const styleRef = useRef(seekbarStyle);
|
||||||
|
styleRef.current = seekbarStyle;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||||
}, [trackId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||||
if (canvasRef.current) {
|
const canvas = canvasRef.current;
|
||||||
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
|
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||||
}
|
}, [seekbarStyle, trackId]);
|
||||||
}, [progress, buffered, trackId, seekbarStyle]);
|
|
||||||
|
|
||||||
// Animated styles: rAF loop
|
// rAF loop — animated styles only.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -829,21 +851,14 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
let rafId: number;
|
let rafId: number;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
animStateRef.current.time += 0.016;
|
animStateRef.current.time += 0.016;
|
||||||
drawSeekbar(
|
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||||
canvas,
|
|
||||||
seekbarStyle,
|
|
||||||
heightsRef.current,
|
|
||||||
progressRef.current,
|
|
||||||
bufferedRef.current,
|
|
||||||
animStateRef.current,
|
|
||||||
);
|
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [seekbarStyle]);
|
}, [seekbarStyle]);
|
||||||
|
|
||||||
// Resize observer
|
// Resize observer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -859,12 +874,23 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const seekRef = useRef(seek);
|
const seekRef = useRef(seek);
|
||||||
seekRef.current = 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(() => {
|
useEffect(() => {
|
||||||
const seekFromX = (clientX: number) => {
|
const seekFromX = (clientX: number) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !trackIdRef.current) return;
|
if (!canvas || !trackIdRef.current) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
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 onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
|
||||||
const onUp = () => { isDragging.current = false; };
|
const onUp = () => { isDragging.current = false; };
|
||||||
@@ -892,7 +918,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
onMouseDown={e => {
|
onMouseDown={e => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
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 => {
|
onMouseMove={e => {
|
||||||
if (!trackId) return;
|
if (!trackId) return;
|
||||||
|
|||||||
@@ -571,6 +571,9 @@ export const deTranslation = {
|
|||||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||||
preloadMode: 'Nächsten Track vorpuffern',
|
preloadMode: 'Nächsten Track vorpuffern',
|
||||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
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)',
|
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||||
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
||||||
preloadCustom: 'Benutzerdefiniert',
|
preloadCustom: 'Benutzerdefiniert',
|
||||||
|
|||||||
@@ -572,6 +572,9 @@ export const enTranslation = {
|
|||||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||||
preloadMode: 'Preload Next Track',
|
preloadMode: 'Preload Next Track',
|
||||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
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)',
|
preloadBalanced: 'Balanced (30 s before end)',
|
||||||
preloadEarly: 'Early (after 5 s of playback)',
|
preloadEarly: 'Early (after 5 s of playback)',
|
||||||
preloadCustom: 'Custom',
|
preloadCustom: 'Custom',
|
||||||
|
|||||||
@@ -569,6 +569,9 @@ export const frTranslation = {
|
|||||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||||
preloadMode: 'Précharger la piste suivante',
|
preloadMode: 'Précharger la piste suivante',
|
||||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon 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)',
|
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||||
preloadEarly: 'Tôt (après 5 s de lecture)',
|
preloadEarly: 'Tôt (après 5 s de lecture)',
|
||||||
preloadCustom: 'Personnalisé',
|
preloadCustom: 'Personnalisé',
|
||||||
|
|||||||
@@ -571,6 +571,9 @@ export const nbTranslation = {
|
|||||||
experimental: 'Eksperimentell',
|
experimental: 'Eksperimentell',
|
||||||
preloadMode: 'Forhåndslast neste spor',
|
preloadMode: 'Forhåndslast neste spor',
|
||||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
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)',
|
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||||
preloadCustom: 'Egendefinert',
|
preloadCustom: 'Egendefinert',
|
||||||
|
|||||||
@@ -569,6 +569,9 @@ export const nlTranslation = {
|
|||||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||||
preloadMode: 'Volgend nummer vooraf laden',
|
preloadMode: 'Volgend nummer vooraf laden',
|
||||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
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)',
|
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||||
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
||||||
preloadCustom: 'Aangepast',
|
preloadCustom: 'Aangepast',
|
||||||
|
|||||||
@@ -597,6 +597,9 @@ export const ruTranslation = {
|
|||||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||||
preloadMode: 'Предзагрузка следующего трека',
|
preloadMode: 'Предзагрузка следующего трека',
|
||||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||||
|
nextTrackBufferingTitle: 'Буферизация следующего трека',
|
||||||
|
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
|
||||||
|
preloadOff: 'Выкл.',
|
||||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||||
preloadEarly: 'Рано (через 5 с после старта)',
|
preloadEarly: 'Рано (через 5 с после старта)',
|
||||||
preloadCustom: 'Свой интервал',
|
preloadCustom: 'Свой интервал',
|
||||||
|
|||||||
@@ -565,6 +565,9 @@ export const zhTranslation = {
|
|||||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||||
preloadMode: '预加载下一曲目',
|
preloadMode: '预加载下一曲目',
|
||||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||||
|
nextTrackBufferingTitle: '下一曲目缓冲',
|
||||||
|
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
|
||||||
|
preloadOff: '关闭',
|
||||||
preloadBalanced: '均衡(结束前30秒)',
|
preloadBalanced: '均衡(结束前30秒)',
|
||||||
preloadEarly: '提前(播放5秒后)',
|
preloadEarly: '提前(播放5秒后)',
|
||||||
preloadCustom: '自定义',
|
preloadCustom: '自定义',
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export default function Favorites() {
|
|||||||
|
|
||||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
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 currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { showToast } from '../utils/toast';
|
|||||||
|
|
||||||
export default function InternetRadio() {
|
export default function InternetRadio() {
|
||||||
const { t } = useTranslation();
|
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<InternetRadioStation[]>([]);
|
const [stations, setStations] = useState<InternetRadioStation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function formatDuration(seconds: number): string {
|
|||||||
export default function Playlists() {
|
export default function Playlists() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { playTrack } = usePlayerStore();
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||||
const removeId = usePlaylistStore((s) => s.removeId);
|
const removeId = usePlaylistStore((s) => s.removeId);
|
||||||
|
|
||||||
|
|||||||
+178
-182
@@ -249,6 +249,10 @@ export default function Settings() {
|
|||||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab === 'audio') {
|
||||||
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTab !== 'storage') return;
|
if (activeTab !== 'storage') return;
|
||||||
getImageCacheSize().then(setImageCacheBytes);
|
getImageCacheSize().then(setImageCacheBytes);
|
||||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||||
@@ -524,8 +528,19 @@ export default function Settings() {
|
|||||||
<span className="toggle-track" />
|
<span className="toggle-track" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="divider" />
|
{/* Next Track Buffering */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Download size={18} />
|
||||||
|
<h2>{t('settings.nextTrackBufferingTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
|
||||||
|
{t('settings.preloadHotCacheMutualExclusive')}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preload mode */}
|
{/* Preload mode */}
|
||||||
<div className="settings-toggle-row">
|
<div className="settings-toggle-row">
|
||||||
@@ -533,31 +548,170 @@ export default function Settings() {
|
|||||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
|
||||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
|
||||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
|
||||||
onClick={() => auth.setPreloadMode(mode)}
|
|
||||||
>
|
|
||||||
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{auth.preloadMode === 'custom' && (
|
|
||||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="checkbox"
|
||||||
min={5} max={120} step={5}
|
checked={auth.preloadMode !== 'off'}
|
||||||
value={auth.preloadCustomSeconds}
|
onChange={e => {
|
||||||
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
if (e.target.checked) {
|
||||||
style={{ width: 120 }}
|
auth.setPreloadMode('balanced');
|
||||||
|
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
|
||||||
|
} else {
|
||||||
|
auth.setPreloadMode('off');
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
<span className="toggle-track" />
|
||||||
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
</label>
|
||||||
</span>
|
</div>
|
||||||
|
{auth.preloadMode !== 'off' && (
|
||||||
|
<>
|
||||||
|
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||||
|
onClick={() => auth.setPreloadMode(mode)}
|
||||||
|
>
|
||||||
|
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{auth.preloadMode === 'custom' && (
|
||||||
|
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5} max={120} step={5}
|
||||||
|
value={auth.preloadCustomSeconds}
|
||||||
|
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||||
|
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
{/* Hot Cache */}
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{t('settings.hotCacheTitle')}
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)' }}>
|
||||||
|
{t('settings.hotCacheAlphaBadge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={auth.hotCacheEnabled}
|
||||||
|
onChange={async e => {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
if (!enabled) {
|
||||||
|
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||||
|
setHotCacheBytes(0);
|
||||||
|
auth.setHotCacheEnabled(false);
|
||||||
|
} else {
|
||||||
|
auth.setHotCacheEnabled(true);
|
||||||
|
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
|
||||||
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||||
|
.then(setHotCacheBytes)
|
||||||
|
.catch(() => setHotCacheBytes(0));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id="hot-cache-enabled-toggle"
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auth.hotCacheEnabled && (
|
||||||
|
<div style={{ marginTop: '1.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||||||
|
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||||
|
/>
|
||||||
|
{auth.hotCacheDownloadDir && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
auth.setHotCacheDownloadDir('');
|
||||||
|
useHotCacheStore.setState({ entries: {} });
|
||||||
|
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||||
|
}}
|
||||||
|
data-tooltip={t('settings.hotCacheDirClear')}
|
||||||
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
||||||
|
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{auth.hotCacheDownloadDir && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||||
|
{t('settings.hotCacheDirHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||||
|
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||||
|
{hotCacheTrackCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" />
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" />
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||||
|
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||||
|
? t('settings.hotCacheDebounceImmediate')
|
||||||
|
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
onClick={async () => {
|
||||||
|
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||||
|
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
||||||
|
setHotCacheBytes(b);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1002,164 +1156,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
|
||||||
<div className="settings-section-header">
|
|
||||||
<HardDrive size={18} />
|
|
||||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
{t('settings.hotCacheTitle')}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('settings.hotCacheAlphaBadge')}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="settings-card">
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
|
||||||
{t('settings.hotCacheDisclaimer')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheEnabled')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={auth.hotCacheEnabled}
|
|
||||||
onChange={async e => {
|
|
||||||
const enabled = e.target.checked;
|
|
||||||
if (!enabled) {
|
|
||||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
|
||||||
setHotCacheBytes(0);
|
|
||||||
auth.setHotCacheEnabled(false);
|
|
||||||
} else {
|
|
||||||
auth.setHotCacheEnabled(true);
|
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
|
||||||
.then(setHotCacheBytes)
|
|
||||||
.catch(() => setHotCacheBytes(0));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
id="hot-cache-enabled-toggle"
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{auth.hotCacheEnabled && (
|
|
||||||
<div style={{ marginTop: '1.25rem' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
|
||||||
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
|
||||||
/>
|
|
||||||
{auth.hotCacheDownloadDir && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={() => {
|
|
||||||
auth.setHotCacheDownloadDir('');
|
|
||||||
useHotCacheStore.setState({ entries: {} });
|
|
||||||
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
||||||
}}
|
|
||||||
data-tooltip={t('settings.hotCacheDirClear')}
|
|
||||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
|
||||||
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{auth.hotCacheDownloadDir && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
|
||||||
{t('settings.hotCacheDirHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<div style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
|
||||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
|
||||||
{hotCacheTrackCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={32}
|
|
||||||
max={20000}
|
|
||||||
step={32}
|
|
||||||
value={snapHotCacheMb(auth.hotCacheMaxMb)}
|
|
||||||
onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
id="hot-cache-max-mb-slider"
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>
|
|
||||||
{snapHotCacheMb(auth.hotCacheMaxMb)} MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={600}
|
|
||||||
step={1}
|
|
||||||
value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))}
|
|
||||||
onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
id="hot-cache-debounce-slider"
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
|
||||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
|
||||||
? t('settings.hotCacheDebounceImmediate')
|
|
||||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 13 }}
|
|
||||||
onClick={async () => {
|
|
||||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
|
||||||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
|
||||||
setHotCacheBytes(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ZIP Export & Archiving */}
|
{/* ZIP Export & Archiving */}
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface AuthState {
|
|||||||
crossfadeEnabled: boolean;
|
crossfadeEnabled: boolean;
|
||||||
crossfadeSecs: number;
|
crossfadeSecs: number;
|
||||||
gaplessEnabled: boolean;
|
gaplessEnabled: boolean;
|
||||||
preloadMode: 'balanced' | 'early' | 'custom';
|
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
|
||||||
preloadCustomSeconds: number;
|
preloadCustomSeconds: number;
|
||||||
infiniteQueueEnabled: boolean;
|
infiniteQueueEnabled: boolean;
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
@@ -133,7 +133,7 @@ interface AuthState {
|
|||||||
setCrossfadeEnabled: (v: boolean) => void;
|
setCrossfadeEnabled: (v: boolean) => void;
|
||||||
setCrossfadeSecs: (v: number) => void;
|
setCrossfadeSecs: (v: number) => void;
|
||||||
setGaplessEnabled: (v: boolean) => void;
|
setGaplessEnabled: (v: boolean) => void;
|
||||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
|
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
|
||||||
setPreloadCustomSeconds: (v: number) => void;
|
setPreloadCustomSeconds: (v: number) => void;
|
||||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||||
setShowArtistImages: (v: boolean) => void;
|
setShowArtistImages: (v: boolean) => void;
|
||||||
@@ -315,7 +315,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||||
setGaplessEnabled: (v) => set({ gaplessEnabled: 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 }),
|
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||||
|
|||||||
@@ -331,11 +331,13 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// Pre-buffer / pre-chain next track based on preload mode.
|
// Pre-buffer / pre-chain next track based on preload mode.
|
||||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
|
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
|
||||||
const remaining = dur - current_time;
|
const remaining = dur - current_time;
|
||||||
const shouldPreload = preloadMode === 'early'
|
const shouldPreload = preloadMode !== 'off' && (
|
||||||
? current_time >= 5
|
preloadMode === 'early'
|
||||||
: preloadMode === 'custom'
|
? current_time >= 5
|
||||||
? remaining < preloadCustomSeconds && remaining > 0
|
: preloadMode === 'custom'
|
||||||
: remaining < 30 && remaining > 0; // balanced (default)
|
? remaining < preloadCustomSeconds && remaining > 0
|
||||||
|
: remaining < 30 && remaining > 0 // balanced (default)
|
||||||
|
);
|
||||||
if (shouldPreload) {
|
if (shouldPreload) {
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
@@ -483,11 +485,28 @@ function handleAudioError(message: string) {
|
|||||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||||
*/
|
*/
|
||||||
export function initAudioListeners(): () => void {
|
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 = [
|
const pending = [
|
||||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
|
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
|
||||||
handleAudioProgress(payload.current_time, payload.duration)
|
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<void>('audio:ended', () => handleAudioEnded()),
|
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||||
|
|||||||
+16
-31
@@ -21,17 +21,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
|
transition: opacity 0.8s ease;
|
||||||
transform: scale(1.05);
|
|
||||||
filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero:hover .hero-bg {
|
|
||||||
filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero:hover .hero-bg {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-dots {
|
.hero-dots {
|
||||||
@@ -909,11 +899,8 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
filter: blur(20px) brightness(0.6);
|
|
||||||
transform: scale(1.1);
|
|
||||||
/* Hide blur edges */
|
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.8;
|
opacity: 0.35;
|
||||||
transition: opacity var(--transition-slow);
|
transition: opacity var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3014,23 +3001,22 @@
|
|||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
|
|
||||||
@keyframes mesh-aura-a {
|
@keyframes mesh-aura-a {
|
||||||
0% { transform: translate3d(0, 0, 0); }
|
0% { transform: translate(0, 0 ); }
|
||||||
33% { transform: translate3d(5%, -6%, 0); }
|
33% { transform: translate(5%, -6% ); }
|
||||||
66% { transform: translate3d(-3%, 2%, 0); }
|
66% { transform: translate(-3%, 2% ); }
|
||||||
100% { transform: translate3d(0, 0, 0); }
|
100% { transform: translate(0, 0 ); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mesh-aura-b {
|
@keyframes mesh-aura-b {
|
||||||
0% { transform: translate3d(0, 0, 0); }
|
0% { transform: translate(0, 0 ); }
|
||||||
50% { transform: translate3d(-6%, 5%, 0); }
|
50% { transform: translate(-6%, 5% ); }
|
||||||
100% { transform: translate3d(0, 0, 0); }
|
100% { transform: translate(0, 0 ); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes portrait-drift {
|
@keyframes portrait-drift {
|
||||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
0%, 100% { transform: translateY(0 ); }
|
||||||
50% { transform: translate3d(0, -10px, 0); }
|
50% { transform: translateY(-10px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.fs-mesh-bg {
|
.fs-mesh-bg {
|
||||||
@@ -3042,12 +3028,11 @@
|
|||||||
pointer-events: none;
|
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 {
|
.fs-mesh-blob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fs-mesh-blob-a {
|
.fs-mesh-blob-a {
|
||||||
@@ -3058,8 +3043,8 @@
|
|||||||
bottom: -35%;
|
bottom: -35%;
|
||||||
background: radial-gradient(ellipse,
|
background: radial-gradient(ellipse,
|
||||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
|
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
|
||||||
transparent 65%);
|
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 96%) 45%,
|
||||||
filter: blur(55px);
|
transparent 70%);
|
||||||
animation: mesh-aura-a 26s ease-in-out infinite;
|
animation: mesh-aura-a 26s ease-in-out infinite;
|
||||||
animation-delay: 350ms;
|
animation-delay: 350ms;
|
||||||
transition: background 400ms ease-in-out;
|
transition: background 400ms ease-in-out;
|
||||||
@@ -3072,8 +3057,8 @@
|
|||||||
top: -25%;
|
top: -25%;
|
||||||
background: radial-gradient(ellipse,
|
background: radial-gradient(ellipse,
|
||||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
|
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
|
||||||
transparent 65%);
|
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 98%) 45%,
|
||||||
filter: blur(65px);
|
transparent 70%);
|
||||||
animation: mesh-aura-b 20s ease-in-out infinite;
|
animation: mesh-aura-b 20s ease-in-out infinite;
|
||||||
animation-delay: 350ms;
|
animation-delay: 350ms;
|
||||||
transition: background 400ms ease-in-out;
|
transition: background 400ms ease-in-out;
|
||||||
|
|||||||
Reference in New Issue
Block a user