mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
Merge branch 'main' into exp/orbit
This commit is contained in:
+28
-24
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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' },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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: 'A–Z (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
@@ -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: 'A–Z (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
@@ -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: 'A–Z (Á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
@@ -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: 'A–Z (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
@@ -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
@@ -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: 'A–Z (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
@@ -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
@@ -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: '系统',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__psyHidden?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user