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
+28 -24
View File
@@ -11,6 +11,7 @@ import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
@@ -27,6 +28,7 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import MostPlayed from './pages/MostPlayed';
import RandomAlbums from './pages/RandomAlbums';
import LuckyMixPage from './pages/LuckyMix';
import SearchResults from './pages/SearchResults';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
@@ -385,11 +387,9 @@ function AppShell() {
};
}, []);
// Pause CSS animations when the window is minimized / hidden.
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
// components.css pauses all running animations while this flag is set.
// Pause CSS animations when the browser tab is hidden (`document.hidden`).
// Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
// Rust (see components.css). WebView2 can keep compositing without the former.
useEffect(() => {
const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
@@ -463,6 +463,7 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
@@ -946,6 +947,7 @@ function TauriEventBridge() {
// JS decides: minimize to tray or exit, based on user setting.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
await invoke('exit_app');
@@ -1144,24 +1146,26 @@ export default function App() {
}, []);
return (
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
<WindowVisibilityProvider>
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
);
}
+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.
+2 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import {
Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
} from 'lucide-react';
export interface NavItemMeta {
@@ -20,6 +20,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
luckyMix: { icon: Sparkles, labelKey: 'sidebar.feelingLucky', to: '/lucky-mix', section: 'library' },
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
+40
View File
@@ -0,0 +1,40 @@
import { useAuthStore } from '../store/authStore';
/**
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
*
* Single source of truth for the gate — previously this logic was inlined in
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
* the sidebarNavReorder filter. All call sites share the same three-way
* predicate:
* 1. User hasn't hidden it via the Settings toggle.
* 2. AudioMuse is enabled for the active server (feature depends on
* audiomuse-backed similar-track quality).
* 3. An active server exists at all.
*
* Callers that additionally care about the "split vs hub" navigation mode
* should combine this with `randomNavMode === 'separate'` explicitly — that's
* an orthogonal UI placement concern, not an availability concern.
*/
export function isLuckyMixAvailable(args: {
activeServerId: string | null | undefined;
audiomuseByServer: Record<string, boolean>;
showLuckyMixMenu: boolean;
}): boolean {
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
if (!showLuckyMixMenu) return false;
if (!activeServerId) return false;
return Boolean(audiomuseByServer[activeServerId]);
}
/**
* React hook form — subscribes to the three authStore slices the predicate
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
* toggle on/off) re-renders the caller automatically.
*/
export function useLuckyMixAvailable(): boolean {
const activeServerId = useAuthStore(s => s.activeServerId);
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
}
+66
View File
@@ -0,0 +1,66 @@
import {
createContext,
useContext,
useState,
useRef,
useEffect,
type ReactNode,
} from 'react';
const WindowVisibilityContext = createContext(false);
/**
* Tracks whether the Tauri window is hidden.
*
* On Windows WebView2, `visibilitychange` and `blur`/`focus` events do not
* fire when `win.hide()` is called. We fall back to polling `document.hidden`
* OR-ed with `window.__psyHidden` (set from Rust before/after `win.hide()` /
* `show()`) — the latter is the reliable signal on WebView2 where
* `document.hidden` may stay false. Adaptive interval: slow while hidden
* (minimize wakeups), 500 ms while visible (catch show without burning CPU).
*/
function isWindowHidden() {
return document.hidden || !!window.__psyHidden;
}
export function WindowVisibilityProvider({ children }: { children: ReactNode }) {
const [hidden, setHidden] = useState(isWindowHidden);
const hiddenRef = useRef(hidden);
useEffect(() => {
hiddenRef.current = isWindowHidden();
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const schedule = () => {
if (cancelled) return;
const interval = hiddenRef.current ? 1000 : 500;
timeoutId = setTimeout(() => {
timeoutId = null;
if (cancelled) return;
const current = isWindowHidden();
if (current !== hiddenRef.current) {
hiddenRef.current = current;
setHidden(current);
}
schedule();
}, interval);
};
schedule();
return () => {
cancelled = true;
if (timeoutId !== null) clearTimeout(timeoutId);
};
}, []);
return (
<WindowVisibilityContext.Provider value={hidden}>
{children}
</WindowVisibilityContext.Provider>
);
}
export function useWindowVisibility() {
return useContext(WindowVisibilityContext);
}
+12 -1
View File
@@ -31,6 +31,7 @@ export const deTranslation = {
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
more: 'Mehr',
feelingLucky: 'Glücks-Mix',
},
home: {
hero: 'Featured',
@@ -283,6 +284,8 @@ export const deTranslation = {
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
mixByAlbums: 'Mix nach Alben',
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
mixByLucky: 'Glücks-Mix',
mixByLuckyDesc: 'Smarter Instant Mix aus Top-Künstlern, Alben und Bewertungen',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -335,6 +338,12 @@ export const deTranslation = {
filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.',
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
},
luckyMix: {
done: 'Glücks-Mix bereit: {{count}} Titel',
failed: 'Glücks-Mix konnte nicht erstellt werden. Bitte erneut versuchen.',
unavailable: 'Glücks-Mix ist für diesen Server nicht verfügbar.',
cancelTooltip: 'Glücks-Mix-Erstellung abbrechen',
},
albums: {
title: 'Alle Alben',
sortByName: 'AZ (Album)',
@@ -720,6 +729,8 @@ export const deTranslation = {
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
randomMixTitle: 'Zufallsmix-Blacklist',
luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
@@ -755,7 +766,7 @@ export const deTranslation = {
sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen',
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glücks-Mix" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
tabInput: 'Eingabe',
tabUsers: 'Benutzer',
shortcutsReset: 'Auf Standard zurücksetzen',
+12 -1
View File
@@ -32,6 +32,7 @@ export const enTranslation = {
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
more: 'More',
feelingLucky: 'Lucky Mix',
},
home: {
hero: 'Featured',
@@ -284,6 +285,8 @@ export const enTranslation = {
mixByTracksDesc: 'Random selection of tracks from your entire library',
mixByAlbums: 'Mix by Albums',
mixByAlbumsDesc: 'Random album picks for your next discovery',
mixByLucky: 'Lucky Mix',
mixByLuckyDesc: 'Smart instant mix from your top artists, albums, and ratings',
},
randomAlbums: {
title: 'Random Albums',
@@ -336,6 +339,12 @@ export const enTranslation = {
filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.',
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
},
luckyMix: {
done: 'Lucky Mix ready: {{count}} tracks',
failed: 'Could not build Lucky Mix. Try again.',
unavailable: 'Lucky Mix is unavailable for this server.',
cancelTooltip: 'Cancel Lucky Mix build',
},
albums: {
title: 'All Albums',
sortByName: 'AZ (Album)',
@@ -722,6 +731,8 @@ export const enTranslation = {
showChangelogOnUpdate: "Show 'What's New' on update",
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
randomMixTitle: 'Random Mix Blacklist',
luckyMixMenuTitle: 'Show Lucky Mix in menu',
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add keyword…',
@@ -757,7 +768,7 @@ export const enTranslation = {
sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible',
randomNavSplitTitle: 'Split Mix navigation',
randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.',
randomNavSplitDesc: 'Show "Random Mix", "Random Albums", and "Lucky Mix" as separate sidebar entries instead of the "Build a Mix" hub.',
tabInput: 'Input',
tabUsers: 'Users',
tabSystem: 'System',
+12 -1
View File
@@ -32,6 +32,7 @@ export const esTranslation = {
expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas',
more: 'Más',
feelingLucky: 'Mezcla Suerte',
},
home: {
hero: 'Destacado',
@@ -284,6 +285,8 @@ export const esTranslation = {
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
mixByLucky: 'Mezcla Suerte',
mixByLuckyDesc: 'Instant Mix inteligente con artistas, álbumes y valoraciones destacadas',
},
randomAlbums: {
title: 'Álbumes Aleatorios',
@@ -336,6 +339,12 @@ export const esTranslation = {
filterPanelDesc: 'Click en una etiqueta de género o nombre de artista en la lista para bloquearlo de futuras mezclas.',
genreClickHint: 'Click en una etiqueta de género para agregarla\ncomo palabra clave filtrada.\nBusca en género, título, álbum y artista.',
},
luckyMix: {
done: 'Mezcla Suerte lista: {{count}} canciones',
failed: 'No se pudo crear la Mezcla Suerte. Inténtalo de nuevo.',
unavailable: 'Mezcla Suerte no está disponible para este servidor.',
cancelTooltip: 'Cancelar creación de Mezcla Suerte',
},
albums: {
title: 'Todos los Álbumes',
sortByName: 'AZ (Álbum)',
@@ -712,6 +721,8 @@ export const esTranslation = {
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
@@ -748,7 +759,7 @@ export const esTranslation = {
sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix',
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
tabInput: 'Entrada',
tabUsers: 'Usuarios',
tabSystem: 'Sistema',
+12 -1
View File
@@ -31,6 +31,7 @@ export const frTranslation = {
expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists',
more: 'Plus',
feelingLucky: 'Mix Chance',
},
home: {
hero: 'En vedette',
@@ -283,6 +284,8 @@ export const frTranslation = {
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
mixByAlbums: 'Mix par albums',
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
mixByLucky: 'Mix Chance',
mixByLuckyDesc: 'Instant Mix intelligent basé sur artistes, albums et notes élevées',
},
randomAlbums: {
title: 'Albums aléatoires',
@@ -335,6 +338,12 @@ export const frTranslation = {
filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.',
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
},
luckyMix: {
done: 'Mix Chance prêt : {{count}} titres',
failed: 'Impossible de créer le Mix Chance. Réessayez.',
unavailable: 'Mix Chance n\'est pas disponible pour ce serveur.',
cancelTooltip: 'Annuler la création du Mix Chance',
},
albums: {
title: 'Tous les albums',
sortByName: 'AZ (Album)',
@@ -707,6 +716,8 @@ export const frTranslation = {
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
randomMixTitle: 'Liste noire du mix aléatoire',
luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
@@ -743,7 +754,7 @@ export const frTranslation = {
sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix',
randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Mix Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
tabInput: 'Entrée',
tabUsers: 'Utilisateurs',
shortcutsReset: 'Réinitialiser',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nbTranslation = {
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
more: 'Mer',
feelingLucky: 'Lykkemiks',
},
home: {
hero: 'Utvalgt',
@@ -283,6 +284,8 @@ export const nbTranslation = {
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
mixByAlbums: 'Miks etter album',
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
mixByLucky: 'Lykkemiks',
mixByLuckyDesc: 'Smart Instant Mix fra toppartister, album og gode vurderinger',
},
randomAlbums: {
title: 'Tilfeldige album',
@@ -335,6 +338,12 @@ export const nbTranslation = {
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.',
genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
},
luckyMix: {
done: 'Lykkemiks klar: {{count}} spor',
failed: 'Kunne ikke lage Lykkemiksen. Prøv igjen.',
unavailable: 'Lykkemiks er ikke tilgjengelig for denne serveren.',
cancelTooltip: 'Avbryt Lykkemiks-bygging',
},
albums: {
title: 'Alle album',
sortByName: 'A–Å (Album)',
@@ -706,6 +715,8 @@ export const nbTranslation = {
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
randomMixTitle: 'Svarteliste for tilfeldig miks',
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
@@ -742,7 +753,7 @@ export const nbTranslation = {
sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon',
randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier',
tabUsers: 'Brukere',
tabSystem: 'System',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nlTranslation = {
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer',
feelingLucky: 'Geluksmix',
},
home: {
hero: 'Uitgelicht',
@@ -282,6 +283,8 @@ export const nlTranslation = {
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
mixByAlbums: 'Mix op albums',
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
mixByLucky: 'Geluksmix',
mixByLuckyDesc: 'Slimme Instant Mix op basis van topartiesten, albums en hoge beoordelingen',
},
randomAlbums: {
title: 'Willekeurige albums',
@@ -334,6 +337,12 @@ export const nlTranslation = {
filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.',
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
},
luckyMix: {
done: 'Geluksmix klaar: {{count}} nummers',
failed: 'Kon de Geluksmix niet maken. Probeer opnieuw.',
unavailable: 'Geluksmix is niet beschikbaar voor deze server.',
cancelTooltip: 'Geluksmix-opbouw annuleren',
},
albums: {
title: 'Alle albums',
sortByName: 'AZ (Album)',
@@ -706,6 +715,8 @@ export const nlTranslation = {
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
randomMixTitle: 'Willekeurige mix-blacklist',
luckyMixMenuTitle: 'Toon Geluksmix in menu',
luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
@@ -742,7 +753,7 @@ export const nlTranslation = {
sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen',
randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluksmix" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
tabInput: 'Invoer',
tabUsers: 'Gebruikers',
shortcutsReset: 'Standaard herstellen',
+14 -1
View File
@@ -32,6 +32,7 @@ export const ruTranslation = {
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё',
feelingLucky: 'Мне повезёт',
},
home: {
hero: 'Подборка',
@@ -297,6 +298,8 @@ export const ruTranslation = {
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
mixByAlbums: 'Микс по альбомам',
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
mixByLucky: 'Мне повезёт',
mixByLuckyDesc: 'Умный Instant Mix из топ-артистов, альбомов и высоких оценок',
},
randomAlbums: {
title: 'Случайные альбомы',
@@ -354,6 +357,12 @@ export const ruTranslation = {
genreClickHint:
'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.',
},
luckyMix: {
done: '«Мне повезёт» готово: {{count}} треков',
failed: 'Не удалось собрать микс «Мне повезёт». Попробуйте ещё раз.',
unavailable: '«Мне повезёт» недоступен на этом сервере.',
cancelTooltip: 'Отменить сборку микса «Мне повезёт»',
},
albums: {
title: 'Все альбомы',
sortByName: 'А–Я (альбом)',
@@ -745,6 +754,9 @@ export const ruTranslation = {
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
randomMixTitle: 'Чёрный список случайного микса',
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
luckyMixMenuDesc:
'Включает «Мне повезёт» в «Собрать микс», а при раздельной навигации — отдельным пунктом меню. Видно только при включённом AudioMuse на активном сервере.',
randomMixBlacklistTitle: 'Свои слова-фильтры',
randomMixBlacklistDesc:
'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).',
@@ -784,7 +796,8 @@ export const ruTranslation = {
sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса',
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
randomNavSplitDesc:
'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод',
tabUsers: 'Пользователи',
tabSystem: 'Система',
+12 -1
View File
@@ -31,6 +31,7 @@ export const zhTranslation = {
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
more: '更多',
feelingLucky: '好运混音',
},
home: {
hero: '精选',
@@ -281,6 +282,8 @@ export const zhTranslation = {
mixByTracksDesc: '从整个媒体库随机选取曲目',
mixByAlbums: '按专辑混音',
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
mixByLucky: '好运混音',
mixByLuckyDesc: '基于高频艺人、专辑和高评分歌曲的智能 Instant Mix',
},
randomAlbums: {
title: '随机专辑',
@@ -333,6 +336,12 @@ export const zhTranslation = {
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
},
luckyMix: {
done: '好运混音已就绪:{{count}} 首',
failed: '生成好运混音失败,请重试。',
unavailable: '当前服务器不支持好运混音。',
cancelTooltip: '取消生成好运混音',
},
albums: {
title: '全部专辑',
sortByName: '按名称排序 (A-Z)',
@@ -701,6 +710,8 @@ export const zhTranslation = {
showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
randomMixTitle: '随机混音黑名单',
luckyMixMenuTitle: '在菜单中显示“好运混音”',
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
randomMixBlacklistPlaceholder: '添加关键词…',
@@ -737,7 +748,7 @@ export const zhTranslation = {
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航',
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
randomNavSplitDesc: '在侧边栏中将随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非创建混音合并入口。',
tabInput: '输入',
tabUsers: '用户',
tabSystem: '系统',
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { buildAndPlayLuckyMix } from '../utils/luckyMix';
export default function LuckyMixPage() {
const navigate = useNavigate();
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void buildAndPlayLuckyMix();
navigate('/now-playing', { replace: true });
}, [navigate]);
return null;
}
+17 -2
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shuffle, Dices } from 'lucide-react';
import { Shuffle, Dices, Sparkles } from 'lucide-react';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
interface MixCard {
icon: React.ElementType;
@@ -28,11 +29,25 @@ const CARDS: MixCard[] = [
export default function RandomLanding() {
const { t } = useTranslation();
const navigate = useNavigate();
// RandomLanding is only reachable in "hub" nav mode, so we don't need to
// gate on randomNavMode here — availability alone is enough.
const luckyMixAvailable = useLuckyMixAvailable();
const cards = luckyMixAvailable
? [
...CARDS,
{
icon: Sparkles,
labelKey: 'randomLanding.mixByLucky',
descKey: 'randomLanding.mixByLuckyDesc',
to: '/lucky-mix',
},
]
: CARDS;
return (
<div className="random-landing">
<div className="random-landing-grid">
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => (
{cards.map(({ icon: Icon, labelKey, descKey, to }) => (
<button
key={to}
className="mix-pick-card"
+24 -1
View File
@@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore';
@@ -2658,6 +2659,25 @@ export default function Settings() {
<div className="divider" style={{ margin: '1rem 0' }} />
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.luckyMixMenuTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.luckyMixMenuDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.luckyMixMenuTitle')}>
<input
type="checkbox"
checked={auth.showLuckyMixMenu}
onChange={e => auth.setShowLuckyMixMenu(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
@@ -4394,11 +4414,14 @@ function SidebarCustomizer() {
itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
});
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
+5
View File
@@ -138,6 +138,8 @@ interface AuthState {
mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number;
/** Show "Lucky Mix" as a regular sidebar/menu item. */
showLuckyMixMenu: boolean;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
@@ -251,6 +253,7 @@ interface AuthState {
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
@@ -361,6 +364,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
showLuckyMixMenu: true,
randomNavMode: 'hub',
musicFolders: [],
musicLibraryFilterByServer: {},
@@ -528,6 +532,7 @@ export const useAuthStore = create<AuthState>()(
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }),
setMusicFolders: (folders) => {
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
interface LuckyMixState {
/** True while `buildAndPlayLuckyMix` is actively assembling a mix. */
isRolling: boolean;
/**
* Set by `cancel()` the build loop polls this between awaits and bails
* out silently when true. Reset to false on `start()` so a new build can
* run after a cancelled one.
*/
cancelRequested: boolean;
start: () => void;
stop: () => void;
cancel: () => void;
}
export const useLuckyMixStore = create<LuckyMixState>((set) => ({
isRolling: false,
cancelRequested: false,
start: () => set({ isRolling: true, cancelRequested: false }),
stop: () => set({ isRolling: false, cancelRequested: false }),
cancel: () => set({ cancelRequested: true }),
}));
+21
View File
@@ -197,6 +197,8 @@ interface PlayerState {
enqueueAt: (tracks: Track[], insertIndex: number) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
pruneUpcomingToCurrent: () => void;
clearQueue: () => void;
isQueueVisible: boolean;
@@ -1364,6 +1366,25 @@ export const usePlayerStore = create<PlayerState>()(
if (!wasPlaying) get().resume();
},
pruneUpcomingToCurrent: () => {
const s = get();
if (s.currentRadio) return;
if (!s.currentTrack) {
if (s.queue.length === 0) return;
set({ queue: [], queueIndex: 0 });
syncQueueToServer([], null, 0);
return;
}
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id);
const newQueue: Track[] =
at >= 0
? s.queue.slice(0, at + 1)
: [s.currentTrack!];
const newIndex = at >= 0 ? at : 0;
set({ queue: newQueue, queueIndex: newIndex });
syncQueueToServer(newQueue, s.currentTrack, s.currentTime);
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
clearAllPlaybackScheduleTimers();
+1
View File
@@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomPicker', visible: true },
{ id: 'randomMix', visible: true },
{ id: 'randomAlbums', visible: true },
{ id: 'luckyMix', visible: true },
{ id: 'artists', visible: true },
{ id: 'genres', visible: true },
{ id: 'favorites', visible: true },
+124 -11
View File
@@ -2427,6 +2427,116 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
/* ─ Lucky Mix pips (shared by the inline queue-lucky-cube indicator) ─ */
.lucky-mix-pip {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.lucky-mix-pip--tl { top: 16px; left: 16px; }
.lucky-mix-pip--tr { top: 16px; right: 16px; }
.lucky-mix-pip--bl { bottom: 16px; left: 16px; }
.lucky-mix-pip--br { bottom: 16px; right: 16px; }
.lucky-mix-pip--center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.queue-lucky-loading {
margin: 2px 10px 4px;
display: flex;
justify-content: center;
padding: 7px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
width: calc(100% - 20px);
transition: background 140ms ease, border-color 140ms ease;
}
.queue-lucky-loading:hover {
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-color: color-mix(in srgb, var(--danger) 28%, transparent);
}
.queue-lucky-loading:hover .queue-lucky-cube {
opacity: 0.55;
}
.queue-lucky-loading__dice {
position: relative;
width: 56px;
height: 30px;
flex: 0 0 auto;
}
.queue-lucky-cube {
position: absolute;
width: 18px;
height: 18px;
border-radius: 5px;
border: 1px solid color-mix(in srgb, #fff 65%, var(--accent) 35%);
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 66%, #fff 34%) 0%,
color-mix(in srgb, var(--accent) 84%, #000 16%) 100%
);
box-shadow:
0 4px 9px rgba(0, 0, 0, 0.28),
inset -2px -3px 5px rgba(0, 0, 0, 0.18),
inset 2px 2px 4px rgba(255, 255, 255, 0.18);
}
.queue-lucky-cube .lucky-mix-pip {
width: 3px;
height: 3px;
}
.queue-lucky-cube .lucky-mix-pip--tl { top: 4px; left: 4px; }
.queue-lucky-cube .lucky-mix-pip--tr { top: 4px; right: 4px; }
.queue-lucky-cube .lucky-mix-pip--bl { bottom: 4px; left: 4px; }
.queue-lucky-cube .lucky-mix-pip--br { bottom: 4px; right: 4px; }
.queue-lucky-cube--a {
left: 0;
top: 11px;
animation: queueLuckyCubeA 760ms ease-in-out infinite;
}
.queue-lucky-cube--b {
left: 18px;
top: 3px;
animation: queueLuckyCubeB 690ms ease-in-out infinite;
}
.queue-lucky-cube--c {
left: 36px;
top: 11px;
animation: queueLuckyCubeC 830ms ease-in-out infinite;
}
@keyframes queueLuckyCubeA {
0% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-6px) rotate(-10deg); }
100% { transform: translateY(0) rotate(0deg); }
}
@keyframes queueLuckyCubeB {
0% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(10deg); }
100% { transform: translateY(0) rotate(0deg); }
}
@keyframes queueLuckyCubeC {
0% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-5px) rotate(12deg); }
100% { transform: translateY(0) rotate(0deg); }
}
/* ─ Playlist edit modal ─ */
.playlist-edit-modal {
max-width: 560px;
@@ -9626,11 +9736,11 @@ html.no-compositing .fs-seekbar-played {
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
/* Pause CSS animations when the window is hidden / minimized
Set via App.tsx on `visibilitychange`. WebView2 on Windows keeps compositing
infinite CSS animations (mesh-aura, portrait-drift, eq-bounce, track-pulse,
led-pulse, ) even when the app is minimized, causing constant GPU use.
Pausing them cuts that to near zero while hidden without touching the
running Rust/audio threads. */
- `data-app-hidden`: App.tsx `visibilitychange` `document.hidden` (all platforms).
- `data-psy-native-hidden`: Rust inject on Tauri `win.hide()` / show (WebView2
often keeps `document.hidden === false` when the native window is hidden).
Either flag pauses infinite animations including portaled nodes and ::pseudo,
without touching Rust/audio threads. */
/* What's New banner + page
Fixed neutral palette so it reads identically on every theme (light and
dark). The banner sits in the sidebar just above Now Playing; the page
@@ -10027,11 +10137,11 @@ html.no-compositing .fs-seekbar-played {
}
.mini-player__volume-popover {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 50;
/* Positioned via inline `style` (createPortal'd to document.body, so
`position: fixed` + computed left/top from the trigger button's rect).
Falls back gracefully if inline style is missing. */
position: fixed;
z-index: 99998;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 8px;
@@ -10268,7 +10378,10 @@ html.no-compositing .fs-seekbar-played {
html[data-app-hidden="true"] *,
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"] *::before,
html[data-psy-native-hidden="true"] *::after {
animation-play-state: paused !important;
}
+449
View File
@@ -0,0 +1,449 @@
import {
filterSongsToActiveLibrary,
getAlbum,
getAlbumList,
getRandomSongs,
getSimilarSongs,
getTopSongs,
type SubsonicAlbum,
type SubsonicSong,
} from '../api/subsonic';
import { invoke } from '@tauri-apps/api/core';
import i18n from '../i18n';
import { useAuthStore } from '../store/authStore';
import { songToTrack, usePlayerStore, type Track } from '../store/playerStore';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { showToast } from './toast';
/**
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
* flips to true. The `catch` handler swallows it silently (no toast, no
* queue restore, no error state) the user already moved on.
*/
class LuckyMixCancelled extends Error {
constructor() {
super('lucky-mix-cancelled');
this.name = 'LuckyMixCancelled';
}
}
interface TopArtist {
id: string;
name: string;
totalPlays: number;
}
const MOST_PLAYED_PAGE_SIZE = 100;
const MOST_PLAYED_MAX_ALBUMS = 500;
const MIX_TARGET_SIZE = 50;
const SEED_TARGET_SIZE = 15;
function sampleRandom<T>(items: T[], count: number): T[] {
if (count <= 0 || items.length === 0) return [];
const arr = [...items];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr.slice(0, Math.min(count, arr.length));
}
function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
const out: SubsonicSong[] = [];
const seen = new Set<string>();
for (const s of items) {
if (!s?.id || seen.has(s.id)) continue;
seen.add(s.id);
out.push(s);
}
return out;
}
function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
return uniqueBySongId([...base, ...incoming]);
}
function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
const map = new Map<string, TopArtist>();
for (const a of albums) {
const plays = a.playCount ?? 0;
if (!a.artistId || !a.artist || plays <= 0) continue;
const prev = map.get(a.artistId);
if (prev) {
prev.totalPlays += plays;
continue;
}
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
}
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
}
async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
const out: SubsonicAlbum[] = [];
let offset = 0;
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
if (!page.length) break;
out.push(...page);
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
offset += MOST_PLAYED_PAGE_SIZE;
}
return out;
}
async function pickSongsForArtist(artist: TopArtist, need: number): Promise<SubsonicSong[]> {
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
if (primary.length >= need) return sampleRandom(primary, need);
const extra: SubsonicSong[] = [];
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
for (const s of rnd) {
if (s.artistId === artist.id || s.artist === artist.name) {
extra.push(s);
}
}
}
return sampleRandom(uniqueBySongId([...primary, ...extra]), need);
}
async function pickSongsForAlbum(albumId: string, need: number): Promise<SubsonicSong[]> {
const full = await getAlbum(albumId).catch(() => null);
if (!full?.songs?.length) return [];
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
return sampleRandom(uniqueBySongId(scopedSongs), need);
}
async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promise<SubsonicSong[]> {
const out: SubsonicSong[] = [];
const push = (s: SubsonicSong) => {
const r = s.userRating ?? 0;
if (r < 4) return;
if (existingIds.has(s.id)) return;
if (out.some(x => x.id === s.id)) return;
out.push(s);
};
for (let i = 0; i < 14 && out.length < need; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
rnd.forEach(push);
}
return sampleRandom(out, need);
}
export async function buildAndPlayLuckyMix(): Promise<void> {
const lucky = useLuckyMixStore.getState();
if (lucky.isRolling) return;
const auth = useAuthStore.getState();
const debugEnabled = auth.loggingMode === 'debug';
const debugSteps: Array<{ step: string; details?: unknown }> = [];
const logStep = (step: string, details?: unknown) => {
if (!debugEnabled) return;
const payload = { step, details };
debugSteps.push(payload);
console.debug('[psysonic][lucky-mix]', payload);
void invoke('frontend_debug_log', {
scope: 'lucky-mix',
message: JSON.stringify(payload),
}).catch(() => {});
};
const songDebug = (songs: SubsonicSong[]) =>
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
const albumDebug = (albums: SubsonicAlbum[]) =>
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
const activeServerId = auth.activeServerId;
const available = isLuckyMixAvailable({
activeServerId,
audiomuseByServer: auth.audiomuseNavidromeByServer,
showLuckyMixMenu: auth.showLuckyMixMenu,
});
logStep('init', {
activeServerId,
available,
showLuckyMixMenu: auth.showLuckyMixMenu,
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
});
if (!available) {
logStep('abort_unavailable');
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
return;
}
// Snapshot the current queue *before* we prune — so if the build fails
// before we ever play a track, we can put it back the way it was instead
// of leaving the user with an empty player.
const playerStateBefore = usePlayerStore.getState();
const queueSnapshot: { queue: Track[]; queueIndex: number } = {
queue: [...playerStateBefore.queue],
queueIndex: playerStateBefore.queueIndex,
};
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
// next tracks while the mix is still building (first playTrack may be delayed).
usePlayerStore.getState().pruneUpcomingToCurrent();
lucky.start();
// Per-run handles. Live outside the try so `finally`/`catch` can read
// `startedPlayback` (drives the queue-restore decision) and clean up the
// player-store subscription unconditionally.
let unsubPlayer: (() => void) | null = null;
let startedPlayback = false;
try {
const queuedIds = new Set<string>();
let allSeedSongs: SubsonicSong[] = [];
const bailIfCancelled = () => {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
};
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
const isBlockedByRating = (song: SubsonicSong) => {
const rating = song.userRating ?? 0;
return rating === 1 || rating === 2;
};
const startImmediatePlayback = (song: SubsonicSong, source: string) => {
if (startedPlayback || !song?.id || isBlockedByRating(song)) return;
startedPlayback = true;
queuedIds.add(song.id);
const track = songToTrack(song);
usePlayerStore.getState().playTrack(track, [track], true);
logStep('start_immediate_playback', {
source,
song: songDebug([song])[0],
queuedCount: queuedIds.size,
});
// Auto-cancel: once we're playing, watch the player store. If the
// current track switches to something the user picked themselves (not
// in our queuedIds set), treat that as "user moved on" and cancel the
// build so we don't later overwrite their choice with our finalised mix.
if (!unsubPlayer) {
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
const prevId = prev.currentTrack?.id ?? null;
const nextId = state.currentTrack?.id ?? null;
if (nextId === prevId) return;
if (!nextId) return;
if (queuedIds.has(nextId)) return;
useLuckyMixStore.getState().cancel();
});
}
};
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
if (useLuckyMixStore.getState().cancelRequested) return 0;
if (reachedTarget()) return 0;
if (!songs.length) return 0;
const deduped = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id) && !isBlockedByRating(s));
if (!deduped.length) return 0;
const candidates = [...deduped];
if (!startedPlayback && candidates.length > 0) {
const first = candidates.shift();
if (first) startImmediatePlayback(first, reason);
}
if (!candidates.length) return 0;
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
if (remaining <= 0) return 0;
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
if (!toAdd.length) return 0;
toAdd.forEach(s => queuedIds.add(s.id));
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
logStep('append_queue_batch', {
reason,
added: toAdd.length,
queuedCount: queuedIds.size,
songs: songDebug(toAdd),
});
return toAdd.length;
};
const frequentAlbums = await fetchFrequentAlbumsPool();
bailIfCancelled();
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
logStep('fetch_frequent_albums', {
fetched: frequentAlbums.length,
withPlays: albumsWithPlays.length,
});
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
const pickedArtists = sampleRandom(topArtists, 2);
logStep('pick_top_artists', {
topArtistsCount: topArtists.length,
pickedArtists,
});
for (const artist of pickedArtists) {
bailIfCancelled();
const songs = await pickSongsForArtist(artist, 3);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
logStep('pick_artist_songs', {
artist,
pickedCount: songs.length,
songs: songDebug(songs),
});
}
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
logStep('pick_top_albums', {
poolCount: albumsWithPlays.length,
pickedAlbums: albumDebug(pickedAlbums),
});
for (const album of pickedAlbums) {
bailIfCancelled();
const songs = await pickSongsForAlbum(album.id, 3);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`);
logStep('pick_album_songs', {
albumId: album.id,
pickedCount: songs.length,
songs: songDebug(songs),
});
}
bailIfCancelled();
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
logStep('pick_rated_songs_4plus_only', {
ratedPickedCount: rated.length,
ratedSongs: songDebug(rated),
});
allSeedSongs = uniqueAppend(allSeedSongs, rated);
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s));
logStep('seed_after_dedup', {
seedCount: seeds.length,
seeds: songDebug(seeds),
});
if (seeds.length < SEED_TARGET_SIZE) {
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
const allowedRnd = rnd.filter(s => !isBlockedByRating(s));
seeds = uniqueAppend(seeds, allowedRnd);
const firstPlayable = allowedRnd[0];
if (firstPlayable) startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
logStep('seed_fill_batch', {
batch: i + 1,
fetched: rnd.length,
seedCount: seeds.length,
});
}
seeds = seeds.slice(0, SEED_TARGET_SIZE);
logStep('seed_fill_end', {
finalSeedCount: seeds.length,
seeds: songDebug(seeds),
});
}
if (seeds.length === 0) {
throw new Error('no-seeds');
}
if (!startedPlayback) {
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s));
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
}
let similarRaw: SubsonicSong[] = [];
let similar: SubsonicSong[] = [];
for (let i = 0; i < seeds.length; i++) {
bailIfCancelled();
const seed = seeds[i];
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
similarRaw = uniqueAppend(similarRaw, oneRaw);
similar = uniqueAppend(similar, oneScoped);
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
if (reachedTarget()) break;
}
const seedForPool = seeds.filter(() => Math.random() < 0.5);
let pool = uniqueBySongId([...seedForPool, ...similar]);
appendSongsToQueue(seedForPool, 'seed-50pct');
logStep('instant_mix', {
seedUsedForInstantMixCount: seeds.length,
seedIncludedInPoolCount: seedForPool.length,
seedIncludedInPool: songDebug(seedForPool),
similarRawCount: similarRaw.length,
similarScopedCount: similar.length,
initialPoolCount: pool.length,
});
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
pool = uniqueAppend(pool, rnd);
appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
logStep('pool_fill_batch', {
batch: i + 1,
fetched: rnd.length,
poolCount: pool.length,
});
if (reachedTarget()) break;
}
bailIfCancelled();
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
appendSongsToQueue(finalSongs, 'finalize-randomized');
logStep('final_queue_state', {
poolCount: pool.length,
queuedCount: queuedIds.size,
queuedTarget: MIX_TARGET_SIZE,
});
if (queuedIds.size === 0) {
throw new Error('empty-mix');
}
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
logStep('done', { queueCount: queuedIds.size });
if (debugEnabled) {
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
void invoke('frontend_debug_log', {
scope: 'lucky-mix',
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
}).catch(() => {});
}
} catch (err) {
// Cancellation is a user-initiated path, not an error. Silent teardown.
if (err instanceof LuckyMixCancelled) {
logStep('cancelled');
if (debugEnabled) {
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
void invoke('frontend_debug_log', {
scope: 'lucky-mix',
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
}).catch(() => {});
}
return;
}
console.error('[psysonic] lucky mix failed:', err);
logStep('failed', { error: String(err) });
if (debugEnabled) {
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
void invoke('frontend_debug_log', {
scope: 'lucky-mix',
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
}).catch(() => {});
}
// If we failed before ever calling playTrack, the queue-prune we did up
// front left the user with nothing. Restore the snapshot so they land
// back where they were pre-click instead of in an empty player.
// If playback did start, leave it alone — their current track plus
// whatever we managed to enqueue is more useful than the old queue.
if (!startedPlayback) {
usePlayerStore.setState({
queue: queueSnapshot.queue,
queueIndex: queueSnapshot.queueIndex,
});
logStep('queue_restored_after_failure', {
restoredCount: queueSnapshot.queue.length,
});
}
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
} finally {
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
useLuckyMixStore.getState().stop();
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
@@ -43,11 +44,15 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
: null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now());
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]);
if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
}
+1 -1
View File
@@ -15,7 +15,7 @@ export function getLibraryItemsForReorder(
): SidebarItemConfig[] {
return items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
});
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
declare global {
interface Window {
__psyHidden?: boolean;
}
}
export {};