feat: queue-ux-improvements (#419)

* feat(queue): add ETA display, equalizer indicator and collapsible now playing

* deleted endsAt and showDuration strings, changed eta update to 30s

* feat(queue): ETA tooltip, persistent Now Playing collapse, EQ bar pause, remove redundant Play icon

* feat(queue): fold ETA into existing total/remaining toggle as third mode

The standalone ETA span next to the track counter is removed; instead the
clickable duration label in the queue header now rotates through three
modes per click: total → remaining → eta → total. Counter (N/M) stays
where it was.

ETA mode keeps the live-feel treatment from the original PR (accent
colour while playing, muted at 50% opacity when paused). The other two
modes use plain accent.

i18n: queue.etaTooltip removed (no longer a separate descriptive label),
queue.showEta added as the action tooltip ('Show estimated end time')
in all 8 locales — matches the showRemaining / showTotal pattern.

* docs(changelog): add #419 queue UX improvements entry

Adds the [1.45.0] / Added entry for this PR's queue panel refinements
(position counter, tri-state duration toggle including ETA, collapsible
Now Playing section, animated EQ indicator).

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
Kveld.
2026-05-02 16:45:55 -03:00
committed by GitHub
parent 5662dafe97
commit 18b4a982ef
33 changed files with 10115 additions and 1498 deletions
+6 -17
View File
@@ -98,24 +98,17 @@ The top header behavior was reworked for narrow widths: search, Live and Orbit c
Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling. Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling.
### Queue — Drag Outside to Remove ### Queue Panel — Position Counter, Tri-State Duration Toggle, Collapsible Now Playing, EQ Indicator
**By [@cucadmuh](https://github.com/cucadmuh), PR [#420](https://github.com/Psychotoxical/psysonic/pull/420)** **By [@kveld9](https://github.com/kveld9), PR [#419](https://github.com/Psychotoxical/psysonic/pull/419)**
You can remove a track from the play queue by dragging its row **outside** the queue sidebar (main window) or outside the mini players queue list. Drop targets still support reordering when you release inside the queue area. The queue panel got a sweep of UX refinements. The header now shows the current position as `(N/M)` next to the queue title for at-a-glance context.
The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself. The clickable duration label in the header rotates through **three** modes per click instead of two: total queue time, remaining time, and **estimated end-of-queue clock time** (e.g. `· 02:10`). ETA updates every 30 seconds, is formatted in the user's locale, and is visually highlighted with the accent colour while playing.
A new chevron next to the queue title **collapses the Now Playing section and queue toolbar**, and the collapsed state is persisted across restarts, so users who treat the queue as a pure list can keep it that way.
### Statistics — Shareable Top-Albums Card The currently playing row in the queue list is now indicated by **animated equalizer bars** to the left of the track title; the bars freeze in place when playback is paused. The previous small play icon next to the title is removed since the EQ bars carry the same signal more clearly.
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#425](https://github.com/Psychotoxical/psysonic/pull/425)**
Statistics page can now export your most-played albums as a shareable PNG, accessible via a share icon next to the **Most Played Albums** section header. Three aspect ratios for different platforms (Story 9:16, Square 1:1, Twitter Card 16:9), three grid sizes (3×3, 4×4, 5×5), with each cover carrying a thin info strip showing rank + play count.
The card pulls the wordmark and accent color directly from the active theme, so a Catppuccin export looks Catppuccin and a Nord export looks Nord. Cover art reuses the existing IndexedDB cache, so no extra Subsonic round-trips on repeat exports. The header label is hardcoded English ("Top Albums") so a shared image stays legible to followers regardless of their language.
Saving uses the native OS save dialog — no silent dump into Downloads, the user picks the path each time. Data source is local-only (Subsonic `getAlbumList(frequent)`); Last.fm is intentionally not used. There is no time-window selector because Navidrome's API exposes only cumulative play counts, not per-event play history.
## Fixed ## Fixed
@@ -124,10 +117,6 @@ Saving uses the native OS save dialog — no silent dump into Downloads, the use
- **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability. - **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability.
- **Track preview audio in sync with progress ring; huge files no longer abort** *(Issue [#421](https://github.com/Psychotoxical/psysonic/issues/421), PR [#423](https://github.com/Psychotoxical/psysonic/pull/423), by [@Psychotoxical](https://github.com/Psychotoxical))*: Previews used to start audio about 25 % into the preview window on mid-track starts because `Sink::try_seek` ran in parallel with `sink.append` while the 30 s `take_duration` cap was already counting wall-clock from append. The seek now runs on the bare source before append, and the progress-ring animation only starts once the engine actually emits `audio:preview-start` — a small loading spinner is shown during the download/decode/seek warmup. The preview HTTP-client timeout was raised from 30 s to 5 min, so multi-hundred-megabyte Hi-Res files no longer abort the download mid-fetch.
- **Windows playback stutter under GPU load** *(Issue [#334](https://github.com/Psychotoxical/psysonic/issues/334), PR [#426](https://github.com/Psychotoxical/psysonic/pull/426), by [@Psychotoxical](https://github.com/Psychotoxical))*: Audio could stutter and crackle on Windows whenever another app put GPU/CPU pressure on the system (browser, 3D apps, games). The WASAPI render thread is now promoted to MMCSS "Pro Audio" via `AvSetMmThreadCharacteristicsW`, so it survives priority contention from competing graphics work. Reproed and validated under a Half-Life parallel-load stresstest. Companion mitigations for high-GPU situations: cosmetic UI animations now pause when the window loses OS focus, and a new **Reduce animations** toggle in **Settings → Appearance** caps animated seekbar styles (pulsewave, particletrail, liquidfill, retrotape) to 30 fps for users on GPU-constrained machines (off by default).
## [1.44.0] - 2026-04-29 ## [1.44.0] - 2026-04-29
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

+414 -966
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -66,7 +66,6 @@ windows = { version = "0.58", features = [
"Win32_Graphics", "Win32_Graphics",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_System_Com", "Win32_System_Com",
"Win32_System_Threading",
"Win32_UI_Controls", "Win32_UI_Controls",
"Win32_UI_Shell", "Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
File diff suppressed because it is too large Load Diff
+4005 -25
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -580,25 +580,6 @@ function AppShell() {
return () => document.removeEventListener('visibilitychange', update); return () => document.removeEventListener('visibilitychange', update);
}, []); }, []);
// Pause cosmetic animations when the window loses OS focus but stays visible
// (alt-tab, click into another app). On low-VRAM laptops WebView2 keeps
// compositing mesh blobs / waveform / marquee at full rate even though the
// user isn't looking — measurable GPU drain reported in issue #334.
useEffect(() => {
const update = () => {
const blurred = !document.hasFocus();
window.__psyBlurred = blurred;
document.documentElement.dataset.appBlurred = blurred ? 'true' : 'false';
};
window.addEventListener('focus', update);
window.addEventListener('blur', update);
update();
return () => {
window.removeEventListener('focus', update);
window.removeEventListener('blur', update);
};
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing'; const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return ( return (
+1 -4
View File
@@ -13,11 +13,9 @@ interface Props {
moreText?: string; moreText?: string;
onLoadMore?: () => Promise<void>; onLoadMore?: () => Promise<void>;
showRating?: boolean; showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
} }
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) { export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -73,7 +71,6 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2> <h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)} )}
<div className="album-row-nav"> <div className="album-row-nav">
{headerExtra}
<button <button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`} className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')} onClick={() => scroll('left')}
+1 -2
View File
@@ -119,7 +119,6 @@ const TrackRow = React.memo(function TrackRow({
const isActive = currentTrackId === song.id; const isActive = currentTrackId === song.id;
// Primitive selector: row only re-renders when *this song's* preview state flips. // Primitive selector: row only re-renders when *this song's* preview state flips.
const isPreviewing = usePreviewStore(s => s.previewingId === song.id); const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
const renderCell = (colDef: ColDef) => { const renderCell = (colDef: ColDef) => {
const key = colDef.key as ColKey; const key = colDef.key as ColKey;
@@ -157,7 +156,7 @@ const TrackRow = React.memo(function TrackRow({
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums'); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
+1 -45
View File
@@ -9,7 +9,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility'; import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu'; import MiniContextMenu from './MiniContextMenu';
@@ -115,18 +115,6 @@ export default function MiniPlayer() {
const [volumeOpen, setVolumeOpen] = useState(false); const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null); const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null); const queueScrollRef = useRef<HTMLDivElement>(null);
const miniQueueWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!queueOpen) return;
const hitTest = (cx: number, cy: number) => {
const el = miniQueueWrapRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
};
return registerQueueDragHitTest(hitTest);
}, [queueOpen]);
const volumeBtnRef = useRef<HTMLButtonElement>(null); const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(null); const volumePopRef = useRef<HTMLDivElement>(null);
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({}); const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
@@ -427,37 +415,6 @@ export default function MiniPlayer() {
return () => el.removeEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [queueOpen, state.queue.length]); }, [queueOpen, state.queue.length]);
// Drop outside the mini queue strip → remove (same UX as main QueuePanel).
useEffect(() => {
if (!queueOpen) return;
const onDocPsyDrop = (e: Event) => {
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
if (!d?.data) return;
const cx = d.clientX;
const cy = d.clientY;
if (typeof cx !== 'number' || typeof cy !== 'number') return;
let parsed: { type?: string; index?: number } | null = null;
try {
parsed = JSON.parse(d.data);
} catch {
return;
}
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
const wrap = miniQueueWrapRef.current;
if (!wrap) return;
const r = wrap.getBoundingClientRect();
const inside =
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
if (inside) return;
psyDragFromIdxRef.current = null;
dropTargetRef.current = null;
setDropTarget(null);
emit('mini:remove', { index: parsed.index }).catch(() => {});
};
document.addEventListener('psy-drop', onDocPsyDrop);
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
}, [queueOpen]);
// Auto-scroll the current track into view when the queue expands. // Auto-scroll the current track into view when the queue expands.
useEffect(() => { useEffect(() => {
if (!queueOpen) return; if (!queueOpen) return;
@@ -676,7 +633,6 @@ export default function MiniPlayer() {
{queueOpen && ( {queueOpen && (
<OverlayScrollArea <OverlayScrollArea
wrapRef={miniQueueWrapRef}
viewportRef={queueScrollRef} viewportRef={queueScrollRef}
className="mini-queue-wrap" className="mini-queue-wrap"
viewportClassName="mini-queue" viewportClassName="mini-queue"
+1 -9
View File
@@ -20,8 +20,6 @@ export type OverlayScrollAreaProps = {
viewportScrollBehaviorAuto?: boolean; viewportScrollBehaviorAuto?: boolean;
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */ /** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
viewportRef?: React.Ref<HTMLDivElement>; viewportRef?: React.Ref<HTMLDivElement>;
/** Ref to the outer wrapper (incl. overlay scrollbar rail). */
wrapRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */ /** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string; viewportId?: string;
/** Optional wheel handler on the scrollable viewport. */ /** Optional wheel handler on the scrollable viewport. */
@@ -51,7 +49,6 @@ export default function OverlayScrollArea({
railInset = 'none', railInset = 'none',
viewportScrollBehaviorAuto = false, viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp, viewportRef: viewportRefProp,
wrapRef: wrapRefProp,
viewportId, viewportId,
viewportOnWheel, viewportOnWheel,
viewportOnTouchMove, viewportOnTouchMove,
@@ -112,11 +109,6 @@ export default function OverlayScrollArea({
assignRef(viewportRefProp, el); assignRef(viewportRefProp, el);
}; };
const setWrapNode = (el: HTMLDivElement | null) => {
wrapRef.current = el;
assignRef(wrapRefProp, el);
};
const rootClass = [ const rootClass = [
'overlay-scroll', 'overlay-scroll',
RAIL_INSET_CLASS[railInset], RAIL_INSET_CLASS[railInset],
@@ -129,7 +121,7 @@ export default function OverlayScrollArea({
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' '); const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
return ( return (
<div ref={setWrapNode} className={rootClass} onMouseMove={onMouseMove}> <div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
<div <div
id={viewportId} id={viewportId}
ref={setViewportNode} ref={setViewportNode}
+1 -2
View File
@@ -237,7 +237,6 @@ export default function PlayerBar() {
const playSlotRef = useRef<HTMLSpanElement>(null); const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining(); const scheduleRemaining = usePlaybackScheduleRemaining();
const isPreviewing = usePreviewStore(s => s.previewingId !== null); const isPreviewing = usePreviewStore(s => s.previewingId !== null);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const previewingTrack = usePreviewStore(s => s.previewingTrack); const previewingTrack = usePreviewStore(s => s.previewingTrack);
const isRadio = !!currentRadio; const isRadio = !!currentRadio;
@@ -313,7 +312,7 @@ export default function PlayerBar() {
<> <>
<footer <footer
ref={playerBarRef} ref={playerBarRef}
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}${showPreviewMeta && previewAudioStarted ? ' audio-started' : ''}`} className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined} style={floatingPlayerBar ? floatingStyle : undefined}
role="region" role="region"
aria-label={t('player.regionLabel')} aria-label={t('player.regionLabel')}
+176 -156
View File
@@ -23,7 +23,7 @@ import { copyTextToClipboard } from '../utils/serverMagicString';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane'; import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo'; import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
@@ -189,20 +189,32 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
); );
} }
type DurationMode = 'total' | 'remaining' | 'eta';
interface QueueHeaderProps { interface QueueHeaderProps {
queue: Track[]; queue: Track[];
queueIndex: number; queueIndex: number;
showRemainingTime: boolean;
setShowRemainingTime: React.Dispatch<React.SetStateAction<boolean>>;
activePlaylist: { id: string; name: string } | null; activePlaylist: { id: string; name: string } | null;
isNowPlayingCollapsed: boolean;
setIsNowPlayingCollapsed: (v: boolean) => void;
durationMode: DurationMode;
setDurationMode: (m: DurationMode) => void;
t: TFunction; t: TFunction;
} }
function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) { function QueueHeader({ queue, queueIndex, activePlaylist, isNowPlayingCollapsed, setIsNowPlayingCollapsed, durationMode, setDurationMode, t }: QueueHeaderProps) {
const currentTime = usePlayerStore((s) => s.currentTime); const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
const isPlaying = usePlayerStore((s) => s.isPlaying);
if (queue.length === 0) return null; const totalSecs = useMemo(() =>
const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0); queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0)); [queue]
);
const futureTracksDuration = useMemo(() =>
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
[queue, queueIndex]
);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
const fmt = (secs: number) => { const fmt = (secs: number) => {
const h = Math.floor(secs / 3600); const h = Math.floor(secs / 3600);
@@ -210,27 +222,53 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
const s = secs % 60; const s = secs % 60;
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`; return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
}; };
const fmtEta = (secs: number) => {
const finishTime = new Date(Date.now() + secs * 1000);
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs)); let dur: string | null = null;
if (queue.length > 0) {
if (durationMode === 'total') dur = fmt(Math.floor(totalSecs));
else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`;
else dur = fmtEta(remainingSecs);
}
const nextMode: DurationMode =
durationMode === 'total' ? 'remaining' :
durationMode === 'remaining' ? 'eta' : 'total';
const nextTooltipKey =
nextMode === 'total' ? 'queue.showTotal' :
nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta';
const isEta = durationMode === 'eta';
return ( return (
<div className="queue-header"> <div className="queue-header">
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}> <div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}> <div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2> <h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
<span {queue.length > 0 && (
onClick={() => setShowRemainingTime((v: boolean) => !v)} <span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")} ({queueIndex + 1}/{queue.length})
style={{ </span>
fontSize: "13px", )}
color: "var(--accent)", {dur !== null && (
whiteSpace: "nowrap", <span
cursor: "pointer", onClick={() => setDurationMode(nextMode)}
userSelect: "none", data-tooltip={t(nextTooltipKey)}
}} style={{
> fontSize: "13px",
{queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur} color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)",
</span> opacity: isEta && !isPlaying ? 0.5 : 1,
whiteSpace: "nowrap",
cursor: "pointer",
userSelect: "none",
}}
>
· {dur}
</span>
)}
</div> </div>
{activePlaylist && ( {activePlaylist && (
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}> <div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
@@ -239,6 +277,17 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
</div> </div>
)} )}
</div> </div>
<button
className="queue-action-btn"
onClick={() => queue.length > 0 && setIsNowPlayingCollapsed(!isNowPlayingCollapsed)}
disabled={queue.length === 0}
data-tooltip={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
aria-label={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
aria-expanded={!isNowPlayingCollapsed}
style={{ marginLeft: '8px', opacity: queue.length === 0 ? 0.3 : 1, cursor: queue.length === 0 ? 'not-allowed' : 'pointer' }}
>
<ChevronDown size={18} style={{ transform: isNowPlayingCollapsed ? 'rotate(-90deg)' : 'none', transition: 'transform 0.2s ease' }} />
</button>
</div> </div>
); );
} }
@@ -301,7 +350,6 @@ function QueuePanelHostOrSolo() {
const clearQueue = usePlayerStore(s => s.clearQueue); const clearQueue = usePlayerStore(s => s.clearQueue);
const reorderQueue = usePlayerStore(s => s.reorderQueue); const reorderQueue = usePlayerStore(s => s.reorderQueue);
const removeTrack = usePlayerStore(s => s.removeTrack);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue); const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt); const enqueueAt = usePlayerStore(s => s.enqueueAt);
@@ -333,7 +381,9 @@ function QueuePanelHostOrSolo() {
const setTab = useLyricsStore(s => s.setTab); const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling); const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false); const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const [durationMode, setDurationMode] = useState<DurationMode>('total');
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [lufsTgtOpen, setLufsTgtOpen] = useState(false); const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({}); const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
@@ -344,6 +394,7 @@ function QueuePanelHostOrSolo() {
const crossfadeBtnRef = useRef<HTMLButtonElement>(null); const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null); const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack); const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const isStorePlaying = usePlayerStore(s => s.isPlaying);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs); const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs); const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb); const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
@@ -441,16 +492,6 @@ function QueuePanelHostOrSolo() {
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
useEffect(() => {
const hitTest = (cx: number, cy: number) => {
const el = asideRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
};
return registerQueueDragHitTest(hitTest);
}, []);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop(); const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
/** Only these drag types may be dropped into the queue. */ /** Only these drag types may be dropped into the queue. */
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']); const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
@@ -518,37 +559,6 @@ function QueuePanelHostOrSolo() {
return () => aside.removeEventListener('psy-drop', onPsyDrop); return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]); }, [enqueueAt]);
// Drag a queue row outside the panel → remove (drop never reaches `aside`).
useEffect(() => {
const onDocPsyDrop = (e: Event) => {
if (!isQueueVisible) return;
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
if (!d?.data) return;
const cx = d.clientX;
const cy = d.clientY;
if (typeof cx !== 'number' || typeof cy !== 'number') return;
let parsed: { type?: string; index?: number } | null = null;
try {
parsed = JSON.parse(d.data);
} catch {
return;
}
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
const aside = asideRef.current;
if (!aside) return;
const r = aside.getBoundingClientRect();
const inside =
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
if (inside) return;
psyDragFromIdxRef.current = null;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
removeTrack(parsed.index);
};
document.addEventListener('psy-drop', onDocPsyDrop);
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
}, [isQueueVisible, removeTrack]);
useEffect(function queueAutoScroll() { useEffect(function queueAutoScroll() {
if (suppressNextAutoScrollRef.current) { if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false; suppressNextAutoScrollRef.current = false;
@@ -647,13 +657,15 @@ function QueuePanelHostOrSolo() {
<QueueHeader <QueueHeader
queue={queue} queue={queue}
queueIndex={queueIndex} queueIndex={queueIndex}
showRemainingTime={showRemainingTime}
setShowRemainingTime={setShowRemainingTime}
activePlaylist={activePlaylist} activePlaylist={activePlaylist}
isNowPlayingCollapsed={isNowPlayingCollapsed}
setIsNowPlayingCollapsed={setIsNowPlayingCollapsed}
durationMode={durationMode}
setDurationMode={setDurationMode}
t={t} t={t}
/> />
{currentTrack && ( {currentTrack && !isNowPlayingCollapsed && (
<div className="queue-current-track"> <div className="queue-current-track">
{(() => { {(() => {
const baseParts = [ const baseParts = [
@@ -869,95 +881,97 @@ function QueuePanelHostOrSolo() {
)} )}
{activeTab === 'queue' ? (<> {activeTab === 'queue' ? (<>
<div className="queue-toolbar"> {!isNowPlayingCollapsed && (
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}> <div className="queue-toolbar">
<Shuffle size={13} /> <button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
</button> <Shuffle size={13} />
<button </button>
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`} <button
onClick={handleSave} className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
disabled={saveState === 'saving'} onClick={handleSave}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')} disabled={saveState === 'saving'}
aria-label={t('queue.savePlaylist')} data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
> aria-label={t('queue.savePlaylist')}
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />} >
</button> {saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}> </button>
<FolderOpen size={13} /> <button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
</button> <FolderOpen size={13} />
<button </button>
className="queue-round-btn" <button
onClick={() => void handleCopyQueueShare()} className="queue-round-btn"
data-tooltip={t('queue.shareQueue')} onClick={() => void handleCopyQueueShare()}
aria-label={t('queue.shareQueue')} data-tooltip={t('queue.shareQueue')}
> aria-label={t('queue.shareQueue')}
<Share2 size={13} /> >
</button> <Share2 size={13} />
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}> </button>
<Trash2 size={13} /> <button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
</button> <Trash2 size={13} />
<div className="queue-toolbar-sep" /> </button>
<button <div className="queue-toolbar-sep" />
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`} <button
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }} className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
data-tooltip={t('queue.gapless')} onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
aria-label={t('queue.gapless')} data-tooltip={t('queue.gapless')}
> aria-label={t('queue.gapless')}
<MoveRight size={13} /> >
</button> <MoveRight size={13} />
<div style={{ position: 'relative' }}> </button>
<button <div style={{ position: 'relative' }}>
ref={crossfadeBtnRef} <button
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`} ref={crossfadeBtnRef}
onClick={() => { className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
if (crossfadeEnabled) { onClick={() => {
setCrossfadeEnabled(false); if (crossfadeEnabled) {
setShowCrossfadePopover(false); setCrossfadeEnabled(false);
} else { setShowCrossfadePopover(false);
setGaplessEnabled(false); } else {
setCrossfadeEnabled(true); setGaplessEnabled(false);
setShowCrossfadePopover(true); setCrossfadeEnabled(true);
} setShowCrossfadePopover(true);
}} }
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}} }}
className="crossfade-popover-slider" data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
/> aria-label={t('queue.crossfade')}
<div className="crossfade-popover-range"> >
<span>0.1s</span><span>10s</span> <Waves size={13} />
</div> </button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
</div> </div>
)} <button
</div> className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
<button onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`} data-tooltip={t('queue.infiniteQueue')}
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)} aria-label={t('queue.infiniteQueue')}
data-tooltip={t('queue.infiniteQueue')} >
aria-label={t('queue.infiniteQueue')} <Infinity size={13} />
> </button>
<Infinity size={13} /> </div>
</button> )}
</div>
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>} {currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
@@ -1036,9 +1050,15 @@ function QueuePanelHostOrSolo() {
}} }}
style={dragStyle} style={dragStyle}
> >
{isPlaying && (
<div className={`eq-bars${isStorePlaying ? '' : ' paused'}`} style={{ marginRight: '8px', flexShrink: 0 }}>
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
</div>
)}
<div className="queue-item-info"> <div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span> <span className="truncate">{track.title}</span>
</div> </div>
<div className="queue-item-artist truncate">{track.artist}</div> <div className="queue-item-artist truncate">{track.artist}</div>
+3 -15
View File
@@ -825,7 +825,7 @@ export function SeekbarPreview({
} }
}; };
const tick = () => { const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) { if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => { pollId = window.setTimeout(() => {
pollId = null; pollId = null;
tick(); tick();
@@ -912,14 +912,11 @@ export default function WaveformSeek({ trackId }: Props) {
const waveformBins = usePlayerStore(s => s.waveformBins); const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle); const seekbarStyle = useAuthStore(s => s.seekbarStyle);
const reducedAnimations = useAuthStore(s => s.reducedAnimations);
// Ref so the subscription callback (closed over at mount) can read the // Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues. // current style without stale-closure issues.
const styleRef = useRef(seekbarStyle); const styleRef = useRef(seekbarStyle);
styleRef.current = seekbarStyle; styleRef.current = seekbarStyle;
const reducedRef = useRef(reducedAnimations);
reducedRef.current = reducedAnimations;
useEffect(() => { useEffect(() => {
if (!trackId) { if (!trackId) {
@@ -1054,7 +1051,6 @@ export default function WaveformSeek({ trackId }: Props) {
animStateRef.current = makeAnimState(); animStateRef.current = makeAnimState();
let rafId: number | null = null; let rafId: number | null = null;
let pollId: number | null = null; let pollId: number | null = null;
let skip = false;
const stop = () => { const stop = () => {
if (rafId !== null) { if (rafId !== null) {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
@@ -1066,22 +1062,14 @@ export default function WaveformSeek({ trackId }: Props) {
} }
}; };
const tick = () => { const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) { if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => { pollId = window.setTimeout(() => {
pollId = null; pollId = null;
tick(); tick();
}, 400); }, 400);
return; return;
} }
// 30 fps cap when reducedAnimations is on: skip every other rAF, advance animStateRef.current.time += 0.016;
// animation time by a doubled delta so wave speed stays the same.
if (reducedRef.current && skip) {
skip = false;
rafId = requestAnimationFrame(tick);
return;
}
skip = reducedRef.current;
animStateRef.current.time += reducedRef.current ? 0.032 : 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
+8 -68
View File
@@ -19,7 +19,6 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Trash2 } from 'lucide-react';
// ── Types ───────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────
export interface DragPayload { export interface DragPayload {
@@ -34,8 +33,6 @@ export interface DragPayload {
interface DragState { interface DragState {
payload: DragPayload | null; payload: DragPayload | null;
position: { x: number; y: number }; position: { x: number; y: number };
/** `queue_reorder` only: true when cursor is outside every registered queue rect */
queueReorderOutside?: boolean;
} }
interface DragDropContextValue { interface DragDropContextValue {
@@ -55,41 +52,10 @@ const Ctx = createContext<DragDropContextValue>({
export const useDragDrop = () => useContext(Ctx); export const useDragDrop = () => useContext(Ctx);
function isQueueReorderDrag(data: string): boolean {
try {
return JSON.parse(data).type === 'queue_reorder';
} catch {
return false;
}
}
/** Hit-tests for queue UI rects (main sidebar + mini-player list). Used so the drag ghost only shows “remove” outside those bounds. */
const queueDragHitTests: Array<(x: number, y: number) => boolean> = [];
/**
* Register a function that returns true when (clientX, clientY) lies inside
* this windows queue drop area. Unregister on cleanup.
*/
export function registerQueueDragHitTest(fn: (x: number, y: number) => boolean): () => void {
queueDragHitTests.push(fn);
return () => {
const i = queueDragHitTests.indexOf(fn);
if (i >= 0) queueDragHitTests.splice(i, 1);
};
}
function computeQueueReorderOutside(clientX: number, clientY: number): boolean {
if (queueDragHitTests.length === 0) return false;
const inside = queueDragHitTests.some((t) => t(clientX, clientY));
return !inside;
}
// ── Ghost overlay ───────────────────────────────────────────────── // ── Ghost overlay ─────────────────────────────────────────────────
function DragGhost({ state }: { state: DragState }) { function DragGhost({ state }: { state: DragState }) {
if (!state.payload) return null; if (!state.payload) return null;
const { label, coverUrl, data } = state.payload; const { label, coverUrl } = state.payload;
const queueReorder = isQueueReorderDrag(data);
const showTrash = queueReorder && state.queueReorderOutside === true;
return createPortal( return createPortal(
<div <div
style={{ style={{
@@ -117,14 +83,6 @@ function DragGhost({ state }: { state: DragState }) {
userSelect: 'none', userSelect: 'none',
}} }}
> >
{showTrash && (
<Trash2
size={16}
strokeWidth={2.25}
aria-hidden
style={{ flexShrink: 0, color: 'var(--danger, var(--ctp-red, #f38ba8))' }}
/>
)}
{coverUrl && ( {coverUrl && (
<img <img
src={coverUrl} src={coverUrl}
@@ -161,13 +119,7 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
// Clear any text selection the browser may have started during the // Clear any text selection the browser may have started during the
// threshold detection phase (mousedown → mousemove before startDrag). // threshold detection phase (mousedown → mousemove before startDrag).
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
setState({ setState({ payload, position: { x, y } });
payload,
position: { x, y },
...(isQueueReorderDrag(payload.data)
? { queueReorderOutside: computeQueueReorderOutside(x, y) }
: {}),
});
}, },
[], [],
); );
@@ -183,18 +135,7 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
// a text-selection drag, which causes element highlighting and // a text-selection drag, which causes element highlighting and
// horizontal auto-scroll in grid containers. // horizontal auto-scroll in grid containers.
e.preventDefault(); e.preventDefault();
setState((prev) => { setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
if (!prev.payload) return prev;
const pos = { x: e.clientX, y: e.clientY };
if (!isQueueReorderDrag(prev.payload.data)) {
return { ...prev, position: pos };
}
return {
...prev,
position: pos,
queueReorderOutside: computeQueueReorderOutside(pos.x, pos.y),
};
});
}; };
/** End drag; optionally fire `psy-drop` at the last known cursor position. */ /** End drag; optionally fire `psy-drop` at the last known cursor position. */
@@ -205,15 +146,14 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
if (dispatchDrop) { if (dispatchDrop) {
const p = stateRef.current.position;
const pl = stateRef.current.payload;
const evt = new CustomEvent('psy-drop', { const evt = new CustomEvent('psy-drop', {
bubbles: true, bubbles: true,
detail: pl detail: stateRef.current.payload,
? { ...pl, clientX: p.x, clientY: p.y }
: pl,
}); });
const el = document.elementFromPoint(p.x, p.y); const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt); if (el) el.dispatchEvent(evt);
} }
+3 -20
View File
@@ -915,8 +915,6 @@ export const deTranslation = {
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.', sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
seekbarStyle: 'Seekbar-Stil', seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen', seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
reducedAnimations: 'Animationen reduzieren',
reducedAnimationsDesc: 'Animierte Seekbar-Stile auf 30 fps drosseln, um die GPU-Last auf schwächerer Hardware zu senken. Optisch kaum wahrnehmbar.',
seekbarTruewave: 'Echte Wellenform', seekbarTruewave: 'Echte Wellenform',
seekbarPseudowave: 'Pseudo-Wellenform', seekbarPseudowave: 'Pseudo-Wellenform',
seekbarLinedot: 'Linie & Punkt', seekbarLinedot: 'Linie & Punkt',
@@ -1109,6 +1107,8 @@ export const deTranslation = {
autoAdded: '— Automatisch hinzugefügt —', autoAdded: '— Automatisch hinzugefügt —',
radioAdded: '— Radio —', radioAdded: '— Radio —',
hide: 'Verbergen', hide: 'Verbergen',
hideNowPlaying: 'Wiedergabeinformationen ausblenden',
showNowPlaying: 'Wiedergabeinformationen anzeigen',
close: 'Schließen', close: 'Schließen',
nextTracks: 'Nächste Titel', nextTracks: 'Nächste Titel',
shareQueue: 'Warteschlangen-Link kopieren', shareQueue: 'Warteschlangen-Link kopieren',
@@ -1118,6 +1118,7 @@ export const deTranslation = {
trackPlural: 'Titel', trackPlural: 'Titel',
showRemaining: 'Restzeit anzeigen', showRemaining: 'Restzeit anzeigen',
showTotal: 'Gesamtzeit anzeigen', showTotal: 'Gesamtzeit anzeigen',
showEta: 'Endzeit anzeigen',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
@@ -1181,24 +1182,6 @@ export const deTranslation = {
topRatedArtists: 'Bestbewertete Künstler', topRatedArtists: 'Bestbewertete Künstler',
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.', noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
noRatedArtists: 'Noch keine bewerteten Künstler.', noRatedArtists: 'Noch keine bewerteten Künstler.',
exportShare: 'Teilen',
exportTitle: 'Top-Alben teilen',
exportSubtitle: 'Ein teilbares Bild deiner meistgespielten Alben erstellen.',
exportFormat: 'Format',
exportFormatStory: 'Story',
exportFormatSquare: 'Quadrat',
exportFormatTwitter: 'Twitter-Card',
exportGrid: 'Raster',
exportGridLabel: '{{n}}×{{n}}',
exportPreview: 'Vorschau',
exportGenerate: 'Erstellen',
exportSave: 'Bild speichern…',
exportCancel: 'Abbrechen',
exportFooterLabel: 'Top-Alben',
exportNotEnough: 'Für ein {{n}}×{{n}}-Raster braucht es mindestens {{count}} Alben in der Library.',
exportSaving: 'Speichere…',
exportSaved: 'Gespeichert.',
exportSaveFailed: 'Bild konnte nicht gespeichert werden.',
}, },
player: { player: {
regionLabel: 'Musikplayer', regionLabel: 'Musikplayer',
+3 -20
View File
@@ -921,8 +921,6 @@ export const enTranslation = {
fsPortraitDim: 'Photo dimming', fsPortraitDim: 'Photo dimming',
seekbarStyle: 'Seekbar Style', seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar', seekbarStyleDesc: 'Choose the look of the player seek bar',
reducedAnimations: 'Reduce animations',
reducedAnimationsDesc: 'Cap animated seekbar styles to 30 fps to lower GPU usage on slower hardware. Visual difference is minimal.',
seekbarTruewave: 'Truewave', seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave', seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Line & Dot', seekbarLinedot: 'Line & Dot',
@@ -1115,6 +1113,8 @@ export const enTranslation = {
autoAdded: '— Added automatically —', autoAdded: '— Added automatically —',
radioAdded: '— Radio —', radioAdded: '— Radio —',
hide: 'Hide', hide: 'Hide',
hideNowPlaying: 'Hide now playing info',
showNowPlaying: 'Show now playing info',
close: 'Close', close: 'Close',
nextTracks: 'Next Tracks', nextTracks: 'Next Tracks',
shareQueue: 'Copy queue share link', shareQueue: 'Copy queue share link',
@@ -1124,6 +1124,7 @@ export const enTranslation = {
trackPlural: 'tracks', trackPlural: 'tracks',
showRemaining: 'Show remaining time', showRemaining: 'Show remaining time',
showTotal: 'Show total time', showTotal: 'Show total time',
showEta: 'Show estimated end time',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
@@ -1187,24 +1188,6 @@ export const enTranslation = {
topRatedArtists: 'Top Rated Artists', topRatedArtists: 'Top Rated Artists',
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.', noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
noRatedArtists: 'No rated artists yet.', noRatedArtists: 'No rated artists yet.',
exportShare: 'Share',
exportTitle: 'Share Top Albums',
exportSubtitle: 'Generate a shareable image of your most-played albums.',
exportFormat: 'Format',
exportFormatStory: 'Story',
exportFormatSquare: 'Square',
exportFormatTwitter: 'Twitter Card',
exportGrid: 'Grid',
exportGridLabel: '{{n}}×{{n}}',
exportPreview: 'Preview',
exportGenerate: 'Generate',
exportSave: 'Save image…',
exportCancel: 'Cancel',
exportFooterLabel: 'Top Albums',
exportNotEnough: 'Need at least {{count}} albums in your library for a {{n}}×{{n}} grid.',
exportSaving: 'Saving…',
exportSaved: 'Saved.',
exportSaveFailed: 'Could not save the image.',
}, },
player: { player: {
regionLabel: 'Music Player', regionLabel: 'Music Player',
+3 -2
View File
@@ -908,8 +908,6 @@ export const esTranslation = {
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.', sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
seekbarStyle: 'Estilo de Barra de Progreso', seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor', seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
reducedAnimations: 'Reducir animaciones',
reducedAnimationsDesc: 'Limita los estilos animados de la barra de progreso a 30 fps para reducir el uso de GPU en hardware más lento. La diferencia visual es mínima.',
seekbarTruewave: 'Forma de Onda Real', seekbarTruewave: 'Forma de Onda Real',
seekbarPseudowave: 'Forma de Onda Pseudo', seekbarPseudowave: 'Forma de Onda Pseudo',
seekbarLinedot: 'Línea y Punto', seekbarLinedot: 'Línea y Punto',
@@ -1102,6 +1100,8 @@ export const esTranslation = {
autoAdded: '— Agregado automáticamente —', autoAdded: '— Agregado automáticamente —',
radioAdded: '— Radio —', radioAdded: '— Radio —',
hide: 'Ocultar', hide: 'Ocultar',
hideNowPlaying: 'Ocultar información de reproducción',
showNowPlaying: 'Mostrar información de reproducción',
close: 'Cerrar', close: 'Cerrar',
nextTracks: 'Siguientes', nextTracks: 'Siguientes',
shareQueue: 'Copiar enlace de la cola', shareQueue: 'Copiar enlace de la cola',
@@ -1111,6 +1111,7 @@ export const esTranslation = {
trackPlural: 'pistas', trackPlural: 'pistas',
showRemaining: 'Mostrar tiempo restante', showRemaining: 'Mostrar tiempo restante',
showTotal: 'Mostrar tiempo total', showTotal: 'Mostrar tiempo total',
showEta: 'Mostrar hora estimada de fin',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
+3 -2
View File
@@ -903,8 +903,6 @@ export const frTranslation = {
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.", sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
seekbarStyle: 'Style de la barre de lecture', seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression', seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
reducedAnimations: 'Réduire les animations',
reducedAnimationsDesc: 'Limiter les styles de barre de lecture animés à 30 fps pour réduire l\'utilisation du GPU sur le matériel plus lent. La différence visuelle est minime.',
seekbarTruewave: 'Forme d\'onde réelle', seekbarTruewave: 'Forme d\'onde réelle',
seekbarPseudowave: 'Forme d\'onde pseudo', seekbarPseudowave: 'Forme d\'onde pseudo',
seekbarLinedot: 'Ligne & point', seekbarLinedot: 'Ligne & point',
@@ -1097,6 +1095,8 @@ export const frTranslation = {
autoAdded: '— Ajouté automatiquement —', autoAdded: '— Ajouté automatiquement —',
radioAdded: '— Radio —', radioAdded: '— Radio —',
hide: 'Masquer', hide: 'Masquer',
hideNowPlaying: 'Masquer les infos de lecture',
showNowPlaying: 'Afficher les infos de lecture',
close: 'Fermer', close: 'Fermer',
nextTracks: 'Pistes suivantes', nextTracks: 'Pistes suivantes',
shareQueue: 'Copier le lien de la file dattente', shareQueue: 'Copier le lien de la file dattente',
@@ -1106,6 +1106,7 @@ export const frTranslation = {
trackPlural: 'pistes', trackPlural: 'pistes',
showRemaining: 'Afficher le temps restant', showRemaining: 'Afficher le temps restant',
showTotal: 'Afficher la durée totale', showTotal: 'Afficher la durée totale',
showEta: 'Afficher l\'heure de fin estimée',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
+3 -2
View File
@@ -902,8 +902,6 @@ export const nbTranslation = {
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.', sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
seekbarStyle: 'Søkefelt-stil', seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet', seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
reducedAnimations: 'Reduser animasjoner',
reducedAnimationsDesc: 'Begrens animerte søkefelt-stiler til 30 fps for å redusere GPU-belastning på tregere maskinvare. Visuell forskjell er minimal.',
seekbarTruewave: 'Ekte bølgeform', seekbarTruewave: 'Ekte bølgeform',
seekbarPseudowave: 'Pseudo-bølgeform', seekbarPseudowave: 'Pseudo-bølgeform',
seekbarLinedot: 'Linje & punkt', seekbarLinedot: 'Linje & punkt',
@@ -1096,6 +1094,8 @@ export const nbTranslation = {
autoAdded: '- Lagt til automatisk -', autoAdded: '- Lagt til automatisk -',
radioAdded: '- Radio -', radioAdded: '- Radio -',
hide: 'Skjul', hide: 'Skjul',
hideNowPlaying: 'Skjul avspillingsinfo',
showNowPlaying: 'Vis avspillingsinfo',
close: 'Lukk', close: 'Lukk',
nextTracks: 'Neste spor', nextTracks: 'Neste spor',
shareQueue: 'Kopiér lenke til kø', shareQueue: 'Kopiér lenke til kø',
@@ -1105,6 +1105,7 @@ export const nbTranslation = {
trackPlural: 'spor', trackPlural: 'spor',
showRemaining: 'Vis gjenværende tid', showRemaining: 'Vis gjenværende tid',
showTotal: 'Vis total tid', showTotal: 'Vis total tid',
showEta: 'Vis estimert sluttid',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
+3 -2
View File
@@ -902,8 +902,6 @@ export const nlTranslation = {
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.', sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
seekbarStyle: 'Zoekbalkstijl', seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk', seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
reducedAnimations: 'Animaties beperken',
reducedAnimationsDesc: 'Beperk geanimeerde zoekbalkstijlen tot 30 fps om GPU-gebruik op tragere hardware te verminderen. Visueel verschil is minimaal.',
seekbarTruewave: 'Echte golfvorm', seekbarTruewave: 'Echte golfvorm',
seekbarPseudowave: 'Pseudo-golfvorm', seekbarPseudowave: 'Pseudo-golfvorm',
seekbarLinedot: 'Lijn & punt', seekbarLinedot: 'Lijn & punt',
@@ -1096,6 +1094,8 @@ export const nlTranslation = {
autoAdded: '— Automatisch toegevoegd —', autoAdded: '— Automatisch toegevoegd —',
radioAdded: '— Radio —', radioAdded: '— Radio —',
hide: 'Verbergen', hide: 'Verbergen',
hideNowPlaying: 'Now playing info verbergen',
showNowPlaying: 'Now playing info weergeven',
close: 'Sluiten', close: 'Sluiten',
nextTracks: 'Volgende nummers', nextTracks: 'Volgende nummers',
shareQueue: 'Wachtrij-deellink kopiëren', shareQueue: 'Wachtrij-deellink kopiëren',
@@ -1105,6 +1105,7 @@ export const nlTranslation = {
trackPlural: 'nummers', trackPlural: 'nummers',
showRemaining: 'Resterende tijd tonen', showRemaining: 'Resterende tijd tonen',
showTotal: 'Totale tijd tonen', showTotal: 'Totale tijd tonen',
showEta: 'Geschatte eindtijd tonen',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB', rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB', rgAlbum: 'A {{db}} dB',
+3 -2
View File
@@ -954,8 +954,6 @@ export const ruTranslation = {
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.', sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
seekbarStyle: 'Стиль прогресс-бара', seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения', seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
reducedAnimations: 'Снизить анимации',
reducedAnimationsDesc: 'Ограничить анимированные стили прогресс-бара 30 кадрами в секунду для снижения нагрузки на GPU на слабом железе. Визуальная разница минимальна.',
seekbarTruewave: 'Реальная форма волны', seekbarTruewave: 'Реальная форма волны',
seekbarPseudowave: 'Псевдо форма волны', seekbarPseudowave: 'Псевдо форма волны',
seekbarLinedot: 'Линия и точка', seekbarLinedot: 'Линия и точка',
@@ -1171,6 +1169,8 @@ export const ruTranslation = {
autoAdded: '— Добавлено автоматически —', autoAdded: '— Добавлено автоматически —',
radioAdded: '— Радио —', radioAdded: '— Радио —',
hide: 'Скрыть', hide: 'Скрыть',
hideNowPlaying: 'Скрыть информацию о воспроизведении',
showNowPlaying: 'Показать информацию о воспроизведении',
close: 'Закрыть', close: 'Закрыть',
nextTracks: 'Дальше', nextTracks: 'Дальше',
shareQueue: 'Копировать ссылку на очередь', shareQueue: 'Копировать ссылку на очередь',
@@ -1180,6 +1180,7 @@ export const ruTranslation = {
trackPlural: 'треков', trackPlural: 'треков',
showRemaining: 'Осталось', showRemaining: 'Осталось',
showTotal: 'Всего', showTotal: 'Всего',
showEta: 'Показать ориентировочное окончание',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: 'Т {{db}} дБ', rgTrack: 'Т {{db}} дБ',
rgAlbum: 'А {{db}} дБ', rgAlbum: 'А {{db}} дБ',
+3 -2
View File
@@ -897,8 +897,6 @@ export const zhTranslation = {
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。', sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
seekbarStyle: '进度条样式', seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观', seekbarStyleDesc: '选择播放进度条的外观',
reducedAnimations: '减少动画',
reducedAnimationsDesc: '将动画进度条样式限制为 30 fps,以降低较慢硬件上的 GPU 占用。视觉差异极小。',
seekbarTruewave: '真实波形', seekbarTruewave: '真实波形',
seekbarPseudowave: '伪波形', seekbarPseudowave: '伪波形',
seekbarLinedot: '线条与点', seekbarLinedot: '线条与点',
@@ -1091,6 +1089,8 @@ export const zhTranslation = {
autoAdded: '— 自动添加 —', autoAdded: '— 自动添加 —',
radioAdded: '— 收音机 —', radioAdded: '— 收音机 —',
hide: '隐藏', hide: '隐藏',
hideNowPlaying: '隐藏播放信息',
showNowPlaying: '显示播放信息',
close: '关闭', close: '关闭',
nextTracks: '即将播放', nextTracks: '即将播放',
shareQueue: '复制队列分享链接', shareQueue: '复制队列分享链接',
@@ -1100,6 +1100,7 @@ export const zhTranslation = {
trackPlural: '首曲目', trackPlural: '首曲目',
showRemaining: '显示剩余时间', showRemaining: '显示剩余时间',
showTotal: '显示总时间', showTotal: '显示总时间',
showEta: '显示预计结束时间',
replayGain: 'ReplayGain', replayGain: 'ReplayGain',
rgTrack: '曲目 {{db}} dB', rgTrack: '曲目 {{db}} dB',
rgAlbum: '专辑 {{db}} dB', rgAlbum: '专辑 {{db}} dB',
+1 -2
View File
@@ -82,7 +82,6 @@ export default function ArtistDetail() {
const currentTrack = usePlayerStore(state => state.currentTrack); const currentTrack = usePlayerStore(state => state.currentTrack);
const isPlaying = usePlayerStore(state => state.isPlaying); const isPlaying = usePlayerStore(state => state.isPlaying);
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const downloadArtist = useOfflineStore(s => s.downloadArtist); const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress); const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
@@ -738,7 +737,7 @@ export default function ArtistDetail() {
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }} onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
+1 -2
View File
@@ -87,7 +87,6 @@ export default function Favorites() {
const currentRadio = usePlayerStore(s => s.currentRadio); const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const starredOverrides = usePlayerStore(s => s.starredOverrides); const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
@@ -694,7 +693,7 @@ export default function Favorites() {
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }} onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
+2 -3
View File
@@ -294,7 +294,6 @@ export default function PlaylistDetail() {
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set()); const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null); const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const zipDownloads = useZipDownloadStore(s => s.downloads); const zipDownloads = useZipDownloadStore(s => s.downloads);
@@ -1710,7 +1709,7 @@ export default function PlaylistDetail() {
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists'); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
@@ -1857,7 +1856,7 @@ export default function PlaylistDetail() {
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" /> <Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button> </button>
<button <button
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { e.stopPropagation(); startPreview(song); }} onClick={e => { e.stopPropagation(); startPreview(song); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
+2 -3
View File
@@ -40,7 +40,6 @@ export default function RandomMix() {
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const starredOverrides = usePlayerStore(s => s.starredOverrides); const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -471,7 +470,7 @@ export default function RandomMix() {
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }} onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
@@ -604,7 +603,7 @@ export default function RandomMix() {
</button> </button>
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }} onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')} aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
+1 -11
View File
@@ -396,7 +396,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' }, { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' }, { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' }, { tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' }, { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform' },
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' }, { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' }, { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
{ tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' }, { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
@@ -3844,16 +3844,6 @@ export default function Settings() {
/> />
))} ))}
</div> </div>
<div className="settings-toggle-row" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.reducedAnimations')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.reducedAnimationsDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.reducedAnimations')}>
<input type="checkbox" checked={auth.reducedAnimations} onChange={e => auth.setReducedAnimations(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
-20
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Share2 } from 'lucide-react';
import { import {
fetchStatisticsFormatSample, fetchStatisticsFormatSample,
fetchStatisticsLibraryAggregates, fetchStatisticsLibraryAggregates,
@@ -10,7 +9,6 @@ import {
} from '../api/subsonic'; } from '../api/subsonic';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import StatsExportModal from '../components/StatsExportModal';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -53,8 +51,6 @@ export default function Statistics() {
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null); const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
const [formatSampleSize, setFormatSampleSize] = useState(0); const [formatSampleSize, setFormatSampleSize] = useState(0);
const [exportOpen, setExportOpen] = useState(false);
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month'); const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]); const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]); const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
@@ -274,17 +270,6 @@ export default function Statistics() {
albums={frequent} albums={frequent}
onLoadMore={() => loadMore('frequent', frequent, setFrequent)} onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
moreText={t('statistics.loadMore')} moreText={t('statistics.loadMore')}
headerExtra={frequent.length >= 9 ? (
<button
type="button"
className="nav-btn"
onClick={() => setExportOpen(true)}
data-tooltip={t('statistics.exportTitle')}
aria-label={t('statistics.exportTitle')}
>
<Share2 size={18} />
</button>
) : undefined}
/> />
<AlbumRow <AlbumRow
@@ -399,11 +384,6 @@ export default function Statistics() {
</div> </div>
)} )}
<StatsExportModal
open={exportOpen}
albums={frequent}
onClose={() => setExportOpen(false)}
/>
</div> </div>
); );
} }
+5 -5
View File
@@ -165,6 +165,8 @@ interface AuthState {
*/ */
lyricsStaticOnly: boolean; lyricsStaticOnly: boolean;
showFullscreenLyrics: boolean; showFullscreenLyrics: boolean;
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
queueNowPlayingCollapsed: boolean;
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */ /** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
fsLyricsStyle: 'rail' | 'apple'; fsLyricsStyle: 'rail' | 'apple';
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */ /** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
@@ -176,8 +178,6 @@ interface AuthState {
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
seekbarStyle: SeekbarStyle; seekbarStyle: SeekbarStyle;
/** Cap animated seekbar styles to 30 fps (and similar GPU-friendly tweaks) for low-end hardware. */
reducedAnimations: boolean;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean; enableHiRes: boolean;
@@ -318,6 +318,7 @@ interface AuthState {
setLyricsMode: (v: 'standard' | 'lyricsplus') => void; setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
setLyricsStaticOnly: (v: boolean) => void; setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void; setShowFullscreenLyrics: (v: boolean) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void;
setFsLyricsStyle: (v: 'rail' | 'apple') => void; setFsLyricsStyle: (v: 'rail' | 'apple') => void;
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void; setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
setShowFsArtistPortrait: (v: boolean) => void; setShowFsArtistPortrait: (v: boolean) => void;
@@ -325,7 +326,6 @@ interface AuthState {
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void; setSeekbarStyle: (v: SeekbarStyle) => void;
setReducedAnimations: (v: boolean) => void;
setEnableHiRes: (v: boolean) => void; setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void; setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void; setHotCacheEnabled: (v: boolean) => void;
@@ -438,6 +438,7 @@ export const useAuthStore = create<AuthState>()(
lyricsMode: 'standard', lyricsMode: 'standard',
lyricsStaticOnly: false, lyricsStaticOnly: false,
showFullscreenLyrics: true, showFullscreenLyrics: true,
queueNowPlayingCollapsed: false,
fsLyricsStyle: 'rail', fsLyricsStyle: 'rail',
sidebarLyricsStyle: 'classic', sidebarLyricsStyle: 'classic',
showFsArtistPortrait: true, showFsArtistPortrait: true,
@@ -445,7 +446,6 @@ export const useAuthStore = create<AuthState>()(
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
seekbarStyle: 'truewave', seekbarStyle: 'truewave',
reducedAnimations: false,
enableHiRes: false, enableHiRes: false,
audioOutputDevice: null, audioOutputDevice: null,
hotCacheEnabled: false, hotCacheEnabled: false,
@@ -599,6 +599,7 @@ export const useAuthStore = create<AuthState>()(
setLyricsMode: (v) => set({ lyricsMode: v }), setLyricsMode: (v) => set({ lyricsMode: v }),
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }), setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: v }),
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }), setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }), setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }), setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
@@ -607,7 +608,6 @@ export const useAuthStore = create<AuthState>()(
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setReducedAnimations: (v) => set({ reducedAnimations: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }), setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
+5 -20
View File
@@ -21,15 +21,6 @@ interface PreviewState {
elapsed: number; elapsed: number;
/** Total preview window in seconds (echoes the duration_sec arg). */ /** Total preview window in seconds (echoes the duration_sec arg). */
duration: number; duration: number;
/**
* True only after the engine has emitted `audio:preview-start` for the
* current `previewingId` i.e. audio is actually playing. Drives the
* progress-ring animation so the ring doesn't run ahead of the speaker
* during the engine's download/decode/seek warmup. Reset to false on every
* `startPreview` call so a switch from track A to track B doesn't carry
* over A's animation state.
*/
audioStarted: boolean;
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>; startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
stopPreview: () => Promise<void>; stopPreview: () => Promise<void>;
@@ -49,7 +40,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingTrack: null, previewingTrack: null,
elapsed: 0, elapsed: 0,
duration: 30, duration: 30,
audioStarted: false,
startPreview: async (song, location) => { startPreview: async (song, location) => {
const auth = useAuthStore.getState(); const auth = useAuthStore.getState();
@@ -87,7 +77,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt }, previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
elapsed: 0, elapsed: 0,
duration: previewDuration, duration: previewDuration,
audioStarted: false,
}); });
try { try {
@@ -101,7 +90,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
} catch (e) { } catch (e) {
// Roll back optimistic state on failure. // Roll back optimistic state on failure.
if (get().previewingId === song.id) { if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); set({ previewingId: null, previewingTrack: null, elapsed: 0 });
} }
throw e; throw e;
} }
@@ -113,19 +102,15 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
await invoke('audio_preview_stop'); await invoke('audio_preview_stop');
} catch { } catch {
/* engine will emit preview-end anyway; clear locally as fallback */ /* engine will emit preview-end anyway; clear locally as fallback */
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); set({ previewingId: null, previewingTrack: null, elapsed: 0 });
} }
}, },
_onStart: (id) => { _onStart: (id) => {
const current = get().previewingId; if (get().previewingId !== id) {
if (current !== id) {
// Engine fired start for an id we didn't track locally — keep id but // Engine fired start for an id we didn't track locally — keep id but
// leave previewingTrack as-is (the caller's startPreview() set it). // leave previewingTrack as-is (the caller's startPreview() set it).
set({ previewingId: id, elapsed: 0, audioStarted: true }); set({ previewingId: id, elapsed: 0 });
} else {
// Audio is now actually playing — unblock the progress-ring animation.
set({ audioStarted: true });
} }
}, },
@@ -136,6 +121,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
_onEnd: (id) => { _onEnd: (id) => {
if (get().previewingId !== id) return; if (get().previewingId !== id) return;
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); set({ previewingId: null, previewingTrack: null, elapsed: 0 });
}, },
})); }));
+7 -46
View File
@@ -1866,27 +1866,7 @@
transform: translateX(0.5px); transform: translateX(0.5px);
} }
/* Loading state: the engine is still downloading/decoding/seeking. A short .playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress {
rotating arc (~25% of the circumference) signals "pending" without
pretending to be progress. 150 ms delay suppresses the spinner for
small/cached files where audio starts almost instantly. */
.playlist-suggestion-preview-btn.is-previewing:not(.audio-started) .playlist-suggestion-preview-ring-progress {
/* circumference = 2π × 10.5 ≈ 65.97 → 25% arc + 75% gap */
stroke-dasharray: 16.5 49.5;
stroke-dashoffset: 0;
animation: playlist-preview-loading 1.1s linear infinite;
animation-delay: 150ms;
}
@keyframes playlist-preview-loading {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -65.97; }
}
/* Animation runs only after `audio-started` is added i.e. once the engine
emitted `audio:preview-start` for this track. Prevents the ring from
sprinting ahead of audio during the engine's download/decode/seek warmup. */
.playlist-suggestion-preview-btn.audio-started .playlist-suggestion-preview-ring-progress {
animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards; animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards;
} }
@@ -2062,6 +2042,11 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
animation-duration: 0.9s; animation-duration: 0.9s;
} }
/* Pause EQ bars animation when playback is paused */
.eq-bars.paused .eq-bar {
animation-play-state: paused;
}
@keyframes eq-bounce { @keyframes eq-bounce {
0%, 0%,
@@ -2507,27 +2492,6 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
stroke-linecap: round; stroke-linecap: round;
stroke-dasharray: 100; stroke-dasharray: 100;
stroke-dashoffset: 100; stroke-dashoffset: 100;
}
/* Loading spinner same logic as the tracklist ring above. The SVG uses
pathLength="100", so the circumference is normalised: 25 unit arc + 75
unit gap, with the offset rolling from 0 to -100. */
.player-bar.is-previewing:not(.audio-started) .player-btn-preview-ring-progress {
stroke-dasharray: 25 75;
stroke-dashoffset: 0;
animation: player-preview-loading 1.1s linear infinite;
animation-delay: 150ms;
}
@keyframes player-preview-loading {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -100; }
}
/* Animation gated on the player-bar `audio-started` flag (set after the
engine emits `audio:preview-start`). Same rationale as the tracklist
preview ring above. */
.player-bar.audio-started .player-btn-preview-ring-progress {
animation: player-preview-progress var(--preview-duration, 30s) linear forwards; animation: player-preview-progress var(--preview-duration, 30s) linear forwards;
} }
@@ -11097,10 +11061,7 @@ html[data-app-hidden="true"] *::before,
html[data-app-hidden="true"] *::after, html[data-app-hidden="true"] *::after,
html[data-psy-native-hidden="true"] *, html[data-psy-native-hidden="true"] *,
html[data-psy-native-hidden="true"] *::before, html[data-psy-native-hidden="true"] *::before,
html[data-psy-native-hidden="true"] *::after, html[data-psy-native-hidden="true"] *::after {
html[data-app-blurred="true"] *,
html[data-app-blurred="true"] *::before,
html[data-app-blurred="true"] *::after {
animation-play-state: paused !important; animation-play-state: paused !important;
} }
-1
View File
@@ -3,7 +3,6 @@
declare global { declare global {
interface Window { interface Window {
__psyHidden?: boolean; __psyHidden?: boolean;
__psyBlurred?: boolean;
} }
} }