Merge branch 'main' into exp/orbit

This commit is contained in:
Psychotoxical
2026-04-24 00:19:14 +02:00
33 changed files with 1255 additions and 88 deletions
+11 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
mixMinRatingArtist,
]);
// Start / restart auto-advance timer
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return;
timerRef.current = null;
if (len <= 1 || windowHidden) return;
timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, []);
}, [windowHidden]);
useEffect(() => {
startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => {
+70 -10
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
@@ -9,6 +10,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea';
@@ -113,7 +115,12 @@ export default function MiniPlayer() {
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeWrapRef = useRef<HTMLDivElement>(null);
const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(null);
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
const hiddenRef = useRef(false);
const isHidden = useWindowVisibility();
useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]);
// ── PsyDnD reorder ──
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
@@ -235,6 +242,7 @@ export default function MiniPlayer() {
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
});
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
if (hiddenRef.current || window.__psyHidden) return;
setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration);
});
@@ -259,13 +267,58 @@ export default function MiniPlayer() {
handleVolumeChange(volume === 0 ? 1 : 0);
};
// Close the volume popover on outside click / Escape.
// Position the portaled volume popover relative to its trigger button.
// Auto-flip above when there is not enough room below (mini window is short).
const updateVolumePopStyle = () => {
if (!volumeBtnRef.current) return;
const rect = volumeBtnRef.current.getBoundingClientRect();
const MARGIN = 6;
const POP_W = 40;
const POP_H = 150;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left + rect.width / 2 - POP_W / 2, 6),
window.innerWidth - POP_W - 6,
);
setVolumePopStyle({
position: 'fixed',
left,
width: POP_W,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!volumeOpen) return;
updateVolumePopStyle();
}, [volumeOpen]);
useEffect(() => {
if (!volumeOpen) return;
const onReposition = () => updateVolumePopStyle();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
};
}, [volumeOpen]);
// Close the volume popover on outside click / Escape. The popover is now
// portaled, so check both the trigger button and the popover ref.
useEffect(() => {
if (!volumeOpen) return;
const onDown = (e: MouseEvent) => {
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) {
setVolumeOpen(false);
}
const target = e.target as Node;
if (
!volumeBtnRef.current?.contains(target) &&
!volumePopRef.current?.contains(target)
) setVolumeOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setVolumeOpen(false);
@@ -448,8 +501,9 @@ export default function MiniPlayer() {
</div>
<div className="mini-player__toolbar" data-tauri-drag-region="false">
<div className="mini-player__volume-wrap" ref={volumeWrapRef}>
<div className="mini-player__volume-wrap">
<button
ref={volumeBtnRef}
type="button"
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => setVolumeOpen(v => !v)}
@@ -460,8 +514,13 @@ export default function MiniPlayer() {
>
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
{volumeOpen && (
<div className="mini-player__volume-popover" data-tauri-drag-region="false">
{volumeOpen && createPortal(
<div
ref={volumePopRef}
className="mini-player__volume-popover"
style={volumePopStyle}
data-tauri-drag-region="false"
>
<span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
<div
className="mini-player__volume-bar"
@@ -496,7 +555,8 @@ export default function MiniPlayer() {
style={{ height: `${Math.round(volume * 100)}%` }}
/>
</div>
</div>
</div>,
document.body,
)}
</div>
+4
View File
@@ -6,6 +6,7 @@ import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -16,6 +17,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems
.filter(cfg => {
@@ -25,6 +28,7 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+10 -5
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps {
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
@@ -46,15 +47,19 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
const [nowMs, setNowMs] = useState(() => Date.now());
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (deadlineMs == null) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500);
if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id);
}, [deadlineMs]);
}, [deadlineMs, windowHidden]);
useLayoutEffect(() => {
if (deadlineMs == null) return;
if (deadlineMs == null || windowHidden) return;
const el = layoutAnchorRef.current;
if (!el) return;
const sync = () => {
@@ -74,7 +79,7 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
window.removeEventListener('scroll', sync, true);
window.clearInterval(iv);
};
}, [deadlineMs, layoutAnchorRef]);
}, [deadlineMs, layoutAnchorRef, windowHidden]);
if (deadlineMs == null || startMs == null || !anchorRect) return null;
+32 -2
View File
@@ -19,6 +19,7 @@ import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -283,6 +284,7 @@ function QueuePanelHostOrSolo() {
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
@@ -671,7 +673,8 @@ function QueuePanelHostOrSolo() {
{t('queue.emptyQueue')}
</div>
) : (
queue.map((track, idx) => {
<>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
@@ -740,9 +743,36 @@ function QueuePanelHostOrSolo() {
{formatTime(track.duration)}
</div>
</div>
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<div className="queue-lucky-loading__dice">
<div className="queue-lucky-cube queue-lucky-cube--a">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--tr" />
<span className="lucky-mix-pip lucky-mix-pip--bl" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--b">
<span className="lucky-mix-pip lucky-mix-pip--center" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--c">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--center" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
</div>
</button>
)}
</React.Fragment>
);
})
})}
</>
)}
</OverlayScrollArea>
</>) : activeTab === 'lyrics' ? (
+12 -2
View File
@@ -27,6 +27,7 @@ import {
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -68,6 +69,10 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const luckyMixBase = useLuckyMixAvailable();
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
// in hub mode it lives inside the Build-a-Mix landing page instead.
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
@@ -95,8 +100,13 @@ export default function Sidebar({
[sidebarItems],
);
const visibleLibraryConfigs = useMemo(
() => libraryItemsForReorder.filter(c => c.visible),
[libraryItemsForReorder],
() =>
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
}),
[libraryItemsForReorder, luckyMixAvailable],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
+43 -7
View File
@@ -727,7 +727,6 @@ export function SeekbarPreview({
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
@@ -738,16 +737,35 @@ export function SeekbarPreview({
}
const animState = makeAnimState();
let t = 0;
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
t += 0.016;
animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick);
rafId = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
tick();
return () => stop();
}, [style]);
return (
@@ -869,14 +887,32 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
animStateRef.current = makeAnimState();
let rafId: number;
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
tick();
return () => stop();
}, [seekbarStyle]);
// Resize observer.