mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
The squash-merge of PR #419 was performed against an outdated PR base that predated several main-side refactors and features. The resulting squash inadvertently re-introduced files that had already been removed (`src-tauri/src/audio.rs` monolith, `app-icon.png`) and reverted main's content for ~20 files (`src-tauri/src/lib.rs` decompose, `src/App.tsx` animation-pause, `src/components/AlbumRow.tsx` headerExtra, etc). This commit: * Restores all collateral-damage files to their pre-#419 main state (5662dafe), including the audio module split, lib decompose, Cargo.toml `windows` dep, animation-pause-on-blur logic, and `reducedAnimations` toggle plumbing in WaveformSeek/Settings. * Keeps the genuine #419 work intact: tri-state duration toggle, position counter, persistent Now Playing collapse, animated EQ indicator (in QueuePanel.tsx, 8 locales, authStore, components.css). * Merges authStore.ts so both `reducedAnimations` (from #426) and `queueNowPlayingCollapsed` (from #419) coexist. * Adds the `.eq-bars.paused` CSS rule manually since components.css needed a5662dafebase + the single #419 addition. * Fixes a latent type error in OverlayScrollArea.tsx (`useRef<T>(null)` is `RefObject<T>` / read-only under current `@types/react`; widened to `useRef<T | null>(null)` so the existing `wrapRef.current = el` assignment compiles). Verified locally with `cargo check` and `npm run build` — both green.
This commit is contained in:
committed by
GitHub
parent
18b4a982ef
commit
e44e6dcdf4
Binary file not shown.
|
Before Width: | Height: | Size: 432 KiB |
Generated
+973
-421
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,7 @@ 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
+25
-4005
File diff suppressed because it is too large
Load Diff
+19
@@ -580,6 +580,25 @@ 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 (
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ 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 }: Props) {
|
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -71,6 +73,7 @@ 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')}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ 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;
|
||||||
@@ -156,7 +157,7 @@ const TrackRow = React.memo(function TrackRow({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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');
|
||||||
|
|||||||
@@ -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 } from '../contexts/DragDropContext';
|
import { useDragDrop, registerQueueDragHitTest } 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,6 +115,18 @@ 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>({});
|
||||||
@@ -415,6 +427,37 @@ 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;
|
||||||
@@ -633,6 +676,7 @@ 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"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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. */
|
||||||
@@ -49,11 +51,12 @@ export default function OverlayScrollArea({
|
|||||||
railInset = 'none',
|
railInset = 'none',
|
||||||
viewportScrollBehaviorAuto = false,
|
viewportScrollBehaviorAuto = false,
|
||||||
viewportRef: viewportRefProp,
|
viewportRef: viewportRefProp,
|
||||||
|
wrapRef: wrapRefProp,
|
||||||
viewportId,
|
viewportId,
|
||||||
viewportOnWheel,
|
viewportOnWheel,
|
||||||
viewportOnTouchMove,
|
viewportOnTouchMove,
|
||||||
}: OverlayScrollAreaProps) {
|
}: OverlayScrollAreaProps) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||||
|
|
||||||
@@ -109,6 +112,11 @@ 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],
|
||||||
@@ -121,7 +129,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={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
|
<div ref={setWrapNode} className={rootClass} onMouseMove={onMouseMove}>
|
||||||
<div
|
<div
|
||||||
id={viewportId}
|
id={viewportId}
|
||||||
ref={setViewportNode}
|
ref={setViewportNode}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ 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;
|
||||||
@@ -312,7 +313,7 @@ export default function PlayerBar() {
|
|||||||
<>
|
<>
|
||||||
<footer
|
<footer
|
||||||
ref={playerBarRef}
|
ref={playerBarRef}
|
||||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
|
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}${showPreviewMeta && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('player.regionLabel')}
|
aria-label={t('player.regionLabel')}
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ export function SeekbarPreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.hidden || window.__psyHidden) {
|
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||||
pollId = window.setTimeout(() => {
|
pollId = window.setTimeout(() => {
|
||||||
pollId = null;
|
pollId = null;
|
||||||
tick();
|
tick();
|
||||||
@@ -912,11 +912,14 @@ 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) {
|
||||||
@@ -1051,6 +1054,7 @@ 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);
|
||||||
@@ -1062,14 +1066,22 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.hidden || window.__psyHidden) {
|
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||||
pollId = window.setTimeout(() => {
|
pollId = window.setTimeout(() => {
|
||||||
pollId = null;
|
pollId = null;
|
||||||
tick();
|
tick();
|
||||||
}, 400);
|
}, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
animStateRef.current.time += 0.016;
|
// 30 fps cap when reducedAnimations is on: skip every other rAF, advance
|
||||||
|
// 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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 {
|
||||||
@@ -33,6 +34,8 @@ 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 {
|
||||||
@@ -52,10 +55,41 @@ 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 window’s 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 } = state.payload;
|
const { label, coverUrl, data } = state.payload;
|
||||||
|
const queueReorder = isQueueReorderDrag(data);
|
||||||
|
const showTrash = queueReorder && state.queueReorderOutside === true;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -83,6 +117,14 @@ 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}
|
||||||
@@ -119,7 +161,13 @@ 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({ payload, position: { x, y } });
|
setState({
|
||||||
|
payload,
|
||||||
|
position: { x, y },
|
||||||
|
...(isQueueReorderDrag(payload.data)
|
||||||
|
? { queueReorderOutside: computeQueueReorderOutside(x, y) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -135,7 +183,18 @@ 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) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
|
setState((prev) => {
|
||||||
|
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. */
|
||||||
@@ -146,14 +205,15 @@ 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: stateRef.current.payload,
|
detail: pl
|
||||||
|
? { ...pl, clientX: p.x, clientY: p.y }
|
||||||
|
: pl,
|
||||||
});
|
});
|
||||||
const el = document.elementFromPoint(
|
const el = document.elementFromPoint(p.x, p.y);
|
||||||
stateRef.current.position.x,
|
|
||||||
stateRef.current.position.y,
|
|
||||||
);
|
|
||||||
if (el) el.dispatchEvent(evt);
|
if (el) el.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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) ?? '';
|
||||||
@@ -737,7 +738,7 @@ export default function ArtistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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')}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ 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);
|
||||||
@@ -693,7 +694,7 @@ export default function Favorites() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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')}
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ 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);
|
||||||
@@ -1709,7 +1710,7 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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');
|
||||||
@@ -1856,7 +1857,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' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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')}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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);
|
||||||
@@ -470,7 +471,7 @@ export default function RandomMix() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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')}
|
||||||
@@ -603,7 +604,7 @@ export default function RandomMix() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
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')}
|
||||||
|
|||||||
+11
-1
@@ -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' },
|
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
|
||||||
{ 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,6 +3844,16 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Share2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchStatisticsFormatSample,
|
fetchStatisticsFormatSample,
|
||||||
fetchStatisticsLibraryAggregates,
|
fetchStatisticsLibraryAggregates,
|
||||||
@@ -9,6 +10,7 @@ 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';
|
||||||
@@ -51,6 +53,8 @@ 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[]>([]);
|
||||||
@@ -270,6 +274,17 @@ 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
|
||||||
@@ -384,6 +399,11 @@ export default function Statistics() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<StatsExportModal
|
||||||
|
open={exportOpen}
|
||||||
|
albums={frequent}
|
||||||
|
onClose={() => setExportOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -165,8 +165,6 @@ 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% */
|
||||||
@@ -178,6 +176,10 @@ 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;
|
||||||
|
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
|
||||||
|
queueNowPlayingCollapsed: 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,7 +320,6 @@ 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;
|
||||||
@@ -326,6 +327,8 @@ 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;
|
||||||
|
setQueueNowPlayingCollapsed: (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,7 +441,6 @@ 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,
|
||||||
@@ -446,6 +448,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
showChangelogOnUpdate: true,
|
showChangelogOnUpdate: true,
|
||||||
lastSeenChangelogVersion: '',
|
lastSeenChangelogVersion: '',
|
||||||
seekbarStyle: 'truewave',
|
seekbarStyle: 'truewave',
|
||||||
|
reducedAnimations: false,
|
||||||
|
queueNowPlayingCollapsed: false,
|
||||||
enableHiRes: false,
|
enableHiRes: false,
|
||||||
audioOutputDevice: null,
|
audioOutputDevice: null,
|
||||||
hotCacheEnabled: false,
|
hotCacheEnabled: false,
|
||||||
@@ -599,7 +603,6 @@ 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 }),
|
||||||
@@ -608,6 +611,8 @@ 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 }),
|
||||||
|
setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: 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 }),
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ 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>;
|
||||||
@@ -40,6 +49,7 @@ 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();
|
||||||
@@ -77,6 +87,7 @@ 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 {
|
||||||
@@ -90,7 +101,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 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -102,15 +113,19 @@ 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 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_onStart: (id) => {
|
_onStart: (id) => {
|
||||||
if (get().previewingId !== id) {
|
const current = get().previewingId;
|
||||||
|
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 });
|
set({ previewingId: id, elapsed: 0, audioStarted: true });
|
||||||
|
} else {
|
||||||
|
// Audio is now actually playing — unblock the progress-ring animation.
|
||||||
|
set({ audioStarted: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -121,6 +136,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 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1866,7 +1866,27 @@
|
|||||||
transform: translateX(0.5px);
|
transform: translateX(0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress {
|
/* Loading state: the engine is still downloading/decoding/seeking. A short
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2492,6 +2512,27 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11061,7 +11102,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__psyHidden?: boolean;
|
__psyHidden?: boolean;
|
||||||
|
__psyBlurred?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user