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
+17 -3
View File
@@ -3064,7 +3064,12 @@ pub async fn audio_chain_preload(
let raw_bytes = Arc::new(data); let raw_bytes = Arc::new(data);
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); // Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
// the still-playing current source). Volume for the chained track is
// applied at the gapless transition in `spawn_progress_task`, not here.
let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
let done_next = Arc::new(AtomicBool::new(false)); let done_next = Arc::new(AtomicBool::new(false));
// Use a dedicated counter for the chained source — it will be swapped into // Use a dedicated counter for the chained source — it will be swapped into
@@ -3116,11 +3121,11 @@ pub async fn audio_chain_preload(
} }
// Append to the existing Sink. The audio hardware stream never stalls. // Append to the existing Sink. The audio hardware stream never stalls.
// Note: `set_volume` is deliberately NOT called here (see comment above).
{ {
let cur = state.current.lock().unwrap(); let cur = state.current.lock().unwrap();
match &cur.sink { match &cur.sink {
Some(sink) => { Some(sink) => {
sink.set_volume(effective_volume);
sink.append(source); sink.append(source);
} }
None => return Ok(()), // playback stopped — bail None => return Ok(()), // playback stopped — bail
@@ -3208,7 +3213,12 @@ fn spawn_progress_task(
// a one-time value copy would go stale immediately. // a one-time value copy would go stale immediately.
samples_played = info.sample_counter; samples_played = info.sample_counter;
// Update tracking state. // Update tracking state and apply the chained track's
// effective volume. Deferred from `audio_chain_preload`
// (which runs ~30 s before the current track ends) to
// avoid changing loudness of the still-playing current
// track. `Sink::set_volume` affects the whole Sink, so it
// must only be called at the boundary, not at preload.
{ {
let mut cur = current_arc.lock().unwrap(); let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear; cur.replay_gain_linear = info.replay_gain_linear;
@@ -3216,6 +3226,10 @@ fn spawn_progress_task(
cur.duration_secs = info.duration_secs; cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0; cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now()); cur.play_started = Some(Instant::now());
if let Some(sink) = &cur.sink {
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
sink.set_volume(effective);
}
} }
// Record the gapless switch timestamp for ghost-command guard. // Record the gapless switch timestamp for ghost-command guard.
+124 -4
View File
@@ -157,6 +157,12 @@ fn export_runtime_logs(path: String) -> Result<usize, String> {
crate::logging::export_logs_to_file(&path) crate::logging::export_logs_to_file(&path)
} }
#[tauri::command]
fn frontend_debug_log(scope: String, message: String) -> Result<(), String> {
crate::app_deprintln!("[frontend][{}] {}", scope, message);
Ok(())
}
#[tauri::command] #[tauri::command]
fn set_subsonic_wire_user_agent( fn set_subsonic_wire_user_agent(
user_agent: String, user_agent: String,
@@ -2747,8 +2753,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
"show_hide" => { "show_hide" => {
if let Some(win) = app.get_webview_window("main") { if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) { if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide(); let _ = win.hide();
} else { } else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
} }
@@ -2781,8 +2789,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let app = tray.app_handle(); let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") { if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) { if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide(); let _ = win.hide();
} else { } else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
} }
@@ -3002,6 +3012,27 @@ fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) {
write_mini_pos(app, MiniPlayerPosition { x, y }); write_mini_pos(app, MiniPlayerPosition { x, y });
} }
/// Returns true when the saved top-left lands inside an available monitor
/// with enough room (≥ 80 px in each axis) to leave a draggable corner of
/// the window on-screen. Used to drop persisted positions that point at a
/// monitor that is no longer enumerated (unplugged, hot-plug detection
/// race during early boot, resolution change, monitor reorder).
fn mini_position_visible(app: &tauri::AppHandle, x: i32, y: i32) -> bool {
const MIN_VISIBLE: i32 = 80;
let monitors = match app.available_monitors() {
Ok(m) if !m.is_empty() => m,
_ => return false,
};
monitors.iter().any(|m| {
let mp = m.position();
let ms = m.size();
x >= mp.x
&& y >= mp.y
&& x + MIN_VISIBLE <= mp.x + ms.width as i32
&& y + MIN_VISIBLE <= mp.y + ms.height as i32
})
}
/// Default position when nothing is persisted: bottom-right of the monitor /// Default position when nothing is persisted: bottom-right of the monitor
/// the main window sits on (falls back to primary). A 24 px logical margin /// the main window sits on (falls back to primary). A 24 px logical margin
/// keeps it off the screen edge; +56 px on the bottom margin avoids most /// keeps it off the screen edge; +56 px on the bottom margin avoids most
@@ -3027,6 +3058,45 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPositi
)) ))
} }
/// JS snippet to inject into a hidden webview to reduce compositor work while
/// the host window is invisible.
///
/// WebView2 on Windows can keep a GPU-backed compositor active even when the
/// native window is hidden. This script does **not** stop arbitrary JS timers
/// or every `requestAnimationFrame` loop — it sets a flag the app reads, zeros
/// `--psy-anim-speed` (for CSS that opts into it), and pauses **@keyframes**
/// animations via `animation-play-state` (not CSS transitions).
///
/// Also sets `data-psy-native-hidden` on `<html>` so global CSS can pause every
/// animation including `::before`/`::after` and portal content under `<body>`
/// when `document.hidden` stays false on some WebView2 builds after `win.hide()`.
const PAUSE_RENDERING_JS: &str = r#"
window.__psyHidden = true;
document.documentElement.setAttribute('data-psy-native-hidden', 'true');
document.documentElement.style.setProperty('--psy-anim-speed', '0');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = 'paused';
});
})();
"#;
/// JS snippet to resume rendering when the window becomes visible again.
const RESUME_RENDERING_JS: &str = r#"
window.__psyHidden = false;
document.documentElement.removeAttribute('data-psy-native-hidden');
document.documentElement.style.removeProperty('--psy-anim-speed');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = '';
});
})();
"#;
/// Build the mini player webview window. Caller decides `visible` so the /// Build the mini player webview window. Caller decides `visible` so the
/// same code path serves both pre-creation (Windows, hidden at app start) /// same code path serves both pre-creation (Windows, hidden at app start)
/// and lazy creation (other platforms, shown on demand). /// and lazy creation (other platforms, shown on demand).
@@ -3054,7 +3124,11 @@ fn build_mini_player_window(
// Resolve target position BEFORE building so the WM places the window // Resolve target position BEFORE building so the WM places the window
// correctly from creation. Calling `set_position` after `build()` is // correctly from creation. Calling `set_position` after `build()` is
// unreliable on several Linux WMs which re-centre hidden windows. // unreliable on several Linux WMs which re-centre hidden windows.
// Drop the persisted position if it would land on a monitor that is
// no longer enumerated (unplugged second monitor, hot-plug race during
// early boot, resolution change) — fall back to the default placement.
let target_physical = read_mini_pos(app) let target_physical = read_mini_pos(app)
.filter(|p| mini_position_visible(app, p.x, p.y))
.map(|p| tauri::PhysicalPosition::new(p.x, p.y)) .map(|p| tauri::PhysicalPosition::new(p.x, p.y))
.or_else(|| default_mini_position(app)); .or_else(|| default_mini_position(app));
let scale = app let scale = app
@@ -3099,9 +3173,18 @@ fn build_mini_player_window(
// fire stray Moved events with default coords during the first paint. // fire stray Moved events with default coords during the first paint.
mark_mini_pos_programmatic(); mark_mini_pos_programmatic();
builder let win = builder
.build() .build()
.map_err(|e| format!("failed to build mini player window: {e}")) .map_err(|e| format!("failed to build mini player window: {e}"))?;
// Inject pause script immediately when the window is created hidden.
// On Windows WebView2 keeps the GPU context alive even with
// `SetIsVisible(false)` — this JS stops all rendering work.
if !visible {
let _ = win.eval(PAUSE_RENDERING_JS);
}
Ok(win)
} }
/// Pre-build the mini player window hidden, so the first `open_mini_player` /// Pre-build the mini player window hidden, so the first `open_mini_player`
@@ -3131,6 +3214,9 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let visible = win.is_visible().unwrap_or(false); let visible = win.is_visible().unwrap_or(false);
if visible { if visible {
// Pause before hide so `__psyHidden` is set while the webview is still
// guaranteed schedulable (mirrors tray / main close ordering).
let _ = win.eval(PAUSE_RENDERING_JS);
win.hide().map_err(|e| e.to_string())?; win.hide().map_err(|e| e.to_string())?;
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize(); let _ = main.unminimize();
@@ -3138,17 +3224,25 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let _ = main.set_focus(); let _ = main.set_focus();
} }
} else { } else {
// Resume rendering before showing — the window needs to be ready
// to paint as soon as it becomes visible.
let _ = win.eval(RESUME_RENDERING_JS);
// Re-applying the saved position after show() — many Linux WMs // Re-applying the saved position after show() — many Linux WMs
// (Mutter, KWin) re-centre hidden windows when they're shown // (Mutter, KWin) re-centre hidden windows when they're shown
// again, ignoring any earlier set_position. Mark the move as // again, ignoring any earlier set_position. Mark the move as
// programmatic so the Moved-event handler doesn't echo the // programmatic so the Moved-event handler doesn't echo the
// intermediate centre coords back to disk. // intermediate centre coords back to disk.
let target = read_mini_pos(&app); // Drop the persisted position if its monitor is gone and fall
// back to the default placement so we don't open off-screen.
let target = read_mini_pos(&app)
.filter(|p| mini_position_visible(&app, p.x, p.y))
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
.or_else(|| default_mini_position(&app));
mark_mini_pos_programmatic(); mark_mini_pos_programmatic();
win.show().map_err(|e| e.to_string())?; win.show().map_err(|e| e.to_string())?;
let _ = win.set_focus(); let _ = win.set_focus();
if let Some(p) = target { if let Some(p) = target {
let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y)); let _ = win.set_position(p);
} }
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize(); let _ = main.minimize();
@@ -3162,6 +3256,7 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") { if let Some(win) = app.get_webview_window("mini") {
let _ = win.eval(PAUSE_RENDERING_JS);
win.hide().map_err(|e| e.to_string())?; win.hide().map_err(|e| e.to_string())?;
} }
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
@@ -3179,9 +3274,11 @@ fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") { if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.eval(PAUSE_RENDERING_JS);
let _ = mini.hide(); let _ = mini.hide();
} }
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.eval(RESUME_RENDERING_JS);
main.unminimize().map_err(|e| e.to_string())?; main.unminimize().map_err(|e| e.to_string())?;
main.show().map_err(|e| e.to_string())?; main.show().map_err(|e| e.to_string())?;
main.set_focus().map_err(|e| e.to_string())?; main.set_focus().map_err(|e| e.to_string())?;
@@ -3189,6 +3286,19 @@ fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`).
#[tauri::command]
fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string())
}
/// Resume rendering work in the current webview. Called when the window
/// becomes visible again.
#[tauri::command]
fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())
}
/// Toggle always-on-top on the mini player window. /// Toggle always-on-top on the mini player window.
/// ///
/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland) /// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland)
@@ -3530,6 +3640,10 @@ pub fn run() {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
// Pause rendering before JS decides whether to hide to tray or exit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
// Let JS decide: minimize to tray or exit, based on user setting. // Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ()); let _ = window.emit("window:close-requested", ());
} }
@@ -3537,6 +3651,9 @@ pub fn run() {
// Native close on the mini: hide instead of destroying so // Native close on the mini: hide instead of destroying so
// state is preserved, and restore the main window. // state is preserved, and restore the main window.
api.prevent_close(); api.prevent_close();
if let Some(w) = window.app_handle().get_webview_window("mini") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
let _ = window.hide(); let _ = window.hide();
if let Some(main) = window.app_handle().get_webview_window("main") { if let Some(main) = window.app_handle().get_webview_window("main") {
let _ = main.unminimize(); let _ = main.unminimize();
@@ -3558,6 +3675,7 @@ pub fn run() {
set_linux_webkit_smooth_scrolling, set_linux_webkit_smooth_scrolling,
set_logging_mode, set_logging_mode,
export_runtime_logs, export_runtime_logs,
frontend_debug_log,
set_subsonic_wire_user_agent, set_subsonic_wire_user_agent,
no_compositing_mode, no_compositing_mode,
is_tiling_wm_cmd, is_tiling_wm_cmd,
@@ -3567,6 +3685,8 @@ pub fn run() {
set_mini_player_always_on_top, set_mini_player_always_on_top,
resize_mini_player, resize_mini_player,
show_main_window, show_main_window,
pause_rendering,
resume_rendering,
register_global_shortcut, register_global_shortcut,
unregister_global_shortcut, unregister_global_shortcut,
mpris_set_metadata, mpris_set_metadata,
+28 -24
View File
@@ -11,6 +11,7 @@ import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView'; import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch'; import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown'; import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel'; import QueuePanel from './components/QueuePanel';
@@ -27,6 +28,7 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail'; import AlbumDetail from './pages/AlbumDetail';
import MostPlayed from './pages/MostPlayed'; import MostPlayed from './pages/MostPlayed';
import RandomAlbums from './pages/RandomAlbums'; import RandomAlbums from './pages/RandomAlbums';
import LuckyMixPage from './pages/LuckyMix';
import SearchResults from './pages/SearchResults'; import SearchResults from './pages/SearchResults';
import Playlists from './pages/Playlists'; import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail'; import PlaylistDetail from './pages/PlaylistDetail';
@@ -385,11 +387,9 @@ function AppShell() {
}; };
}, []); }, []);
// Pause CSS animations when the window is minimized / hidden. // Pause CSS animations when the browser tab is hidden (`document.hidden`).
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura, // Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows // Rust (see components.css). WebView2 can keep compositing without the former.
// 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.
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false'; document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
@@ -463,6 +463,7 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} /> <Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} /> <Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} /> <Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} /> <Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} /> <Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} /> <Route path="/search/advanced" element={<AdvancedSearch />} />
@@ -946,6 +947,7 @@ function TauriEventBridge() {
// JS decides: minimize to tray or exit, based on user setting. // JS decides: minimize to tray or exit, based on user setting.
const u = await listen('window:close-requested', async () => { const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) { if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide(); await getCurrentWindow().hide();
} else { } else {
await invoke('exit_app'); await invoke('exit_app');
@@ -1144,24 +1146,26 @@ export default function App() {
}, []); }, []);
return ( return (
<BrowserRouter> <WindowVisibilityProvider>
<PasteClipboardHandler /> <BrowserRouter>
<TauriEventBridge /> <PasteClipboardHandler />
<Routes> <TauriEventBridge />
<Route path="/login" element={<Login />} /> <Routes>
<Route <Route path="/login" element={<Login />} />
path="/*" <Route
element={ path="/*"
<RequireAuth> element={
<DragDropProvider> <RequireAuth>
<AppShell /> <DragDropProvider>
</DragDropProvider> <AppShell />
</RequireAuth> </DragDropProvider>
} </RequireAuth>
/> }
</Routes> />
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />} </Routes>
<ZipDownloadOverlay /> {exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
</BrowserRouter> <ZipDownloadOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
); );
} }
+11 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; } if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
mixMinRatingArtist, mixMinRatingArtist,
]); ]);
// Start / restart auto-advance timer // Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return; timerRef.current = null;
if (len <= 1 || windowHidden) return;
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setActiveIdx(prev => (prev + 1) % len); setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS); }, INTERVAL_MS);
}, []); }, [windowHidden]);
useEffect(() => { useEffect(() => {
startTimer(albums.length); startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); }; return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]); }, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => { 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 { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -9,6 +10,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu'; import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea'; import OverlayScrollArea from './OverlayScrollArea';
@@ -113,7 +115,12 @@ export default function MiniPlayer() {
const [volumeOpen, setVolumeOpen] = useState(false); const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null); const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null); const queueScrollRef = useRef<HTMLDivElement>(null);
const 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 ── // ── PsyDnD reorder ──
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove // 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); if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
}); });
const unProgress = listen<ProgressPayload>('audio:progress', (e) => { const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
if (hiddenRef.current || window.__psyHidden) return;
setCurrentTime(e.payload.current_time); setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration); if (e.payload.duration > 0) setDuration(e.payload.duration);
}); });
@@ -259,13 +267,58 @@ export default function MiniPlayer() {
handleVolumeChange(volume === 0 ? 1 : 0); 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(() => { useEffect(() => {
if (!volumeOpen) return; if (!volumeOpen) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) { const target = e.target as Node;
setVolumeOpen(false); if (
} !volumeBtnRef.current?.contains(target) &&
!volumePopRef.current?.contains(target)
) setVolumeOpen(false);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setVolumeOpen(false); if (e.key === 'Escape') setVolumeOpen(false);
@@ -448,8 +501,9 @@ export default function MiniPlayer() {
</div> </div>
<div className="mini-player__toolbar" data-tauri-drag-region="false"> <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 <button
ref={volumeBtnRef}
type="button" type="button"
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`} className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => setVolumeOpen(v => !v)} onClick={() => setVolumeOpen(v => !v)}
@@ -460,8 +514,13 @@ export default function MiniPlayer() {
> >
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />} {volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button> </button>
{volumeOpen && ( {volumeOpen && createPortal(
<div className="mini-player__volume-popover" data-tauri-drag-region="false"> <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> <span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
<div <div
className="mini-player__volume-bar" className="mini-player__volume-bar"
@@ -496,7 +555,8 @@ export default function MiniPlayer() {
style={{ height: `${Math.round(volume * 100)}%` }} style={{ height: `${Math.round(volume * 100)}%` }}
/> />
</div> </div>
</div> </div>,
document.body,
)} )}
</div> </div>
+4
View File
@@ -6,6 +6,7 @@ import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems'; import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']); 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 serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems const items = sidebarItems
.filter(cfg => { .filter(cfg => {
@@ -25,6 +28,7 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (BOTTOM_NAV_ROUTES.has(item.to)) return false; if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true; return true;
}) })
.map(cfg => ALL_NAV_ITEMS[cfg.id]); .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 { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps { export interface PlaybackScheduleBadgeProps {
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */ /** 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 [nowMs, setNowMs] = useState(() => Date.now());
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null); const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500); const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [deadlineMs]); }, [deadlineMs, windowHidden]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const el = layoutAnchorRef.current; const el = layoutAnchorRef.current;
if (!el) return; if (!el) return;
const sync = () => { const sync = () => {
@@ -74,7 +79,7 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
window.removeEventListener('scroll', sync, true); window.removeEventListener('scroll', sync, true);
window.clearInterval(iv); window.clearInterval(iv);
}; };
}, [deadlineMs, layoutAnchorRef]); }, [deadlineMs, layoutAnchorRef, windowHidden]);
if (deadlineMs == null || startMs == null || !anchorRect) return null; 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 NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea'; import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -283,6 +284,7 @@ function QueuePanelHostOrSolo() {
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab); const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false); const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
@@ -671,7 +673,8 @@ function QueuePanelHostOrSolo() {
{t('queue.emptyQueue')} {t('queue.emptyQueue')}
</div> </div>
) : ( ) : (
queue.map((track, idx) => { <>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex; const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
@@ -740,9 +743,36 @@ function QueuePanelHostOrSolo() {
{formatTime(track.duration)} {formatTime(track.duration)}
</div> </div>
</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> </React.Fragment>
); );
}) })}
</>
)} )}
</OverlayScrollArea> </OverlayScrollArea>
</>) : activeTab === 'lyrics' ? ( </>) : activeTab === 'lyrics' ? (
+12 -2
View File
@@ -27,6 +27,7 @@ import {
isSidebarNavItemUserHideable, isSidebarNavItemUserHideable,
type SidebarNavDropTarget, type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder'; } from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000; const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -68,6 +69,10 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems); const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode); 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 [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsRaw = usePlaylistStore(s => s.playlists);
@@ -95,8 +100,13 @@ export default function Sidebar({
[sidebarItems], [sidebarItems],
); );
const visibleLibraryConfigs = useMemo( 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( const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible), () => systemItemsForReorder.filter(c => c.visible),
+43 -7
View File
@@ -727,7 +727,6 @@ export function SeekbarPreview({
onClick: () => void; onClick: () => void;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -738,16 +737,35 @@ export function SeekbarPreview({
} }
const animState = makeAnimState(); const animState = makeAnimState();
let t = 0; 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 = () => { const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
t += 0.016; t += 0.016;
animState.time = t; animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t)); const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18); const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState); drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
rafRef.current = requestAnimationFrame(tick); tick();
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; return () => stop();
}, [style]); }, [style]);
return ( return (
@@ -869,14 +887,32 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
animStateRef.current = makeAnimState(); 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 = () => { const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016; animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
rafId = requestAnimationFrame(tick); tick();
return () => cancelAnimationFrame(rafId); return () => stop();
}, [seekbarStyle]); }, [seekbarStyle]);
// Resize observer. // Resize observer.
+2 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { import {
Disc3, Users, Music4, Radio, Heart, BarChart3, Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp, HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
export interface NavItemMeta { 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' }, randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' }, randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', 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' }, artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' }, genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', 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', expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen', collapsePlaylists: 'Playlists einklappen',
more: 'Mehr', more: 'Mehr',
feelingLucky: 'Glücks-Mix',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
@@ -283,6 +284,8 @@ export const deTranslation = {
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek', mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
mixByAlbums: 'Mix nach Alben', mixByAlbums: 'Mix nach Alben',
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen', mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
mixByLucky: 'Glücks-Mix',
mixByLuckyDesc: 'Smarter Instant Mix aus Top-Künstlern, Alben und Bewertungen',
}, },
randomAlbums: { randomAlbums: {
title: 'Zufallsalben', 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.', 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.', 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: { albums: {
title: 'Alle Alben', title: 'Alle Alben',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -720,6 +729,8 @@ export const deTranslation = {
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", 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.', 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', 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
@@ -755,7 +766,7 @@ export const deTranslation = {
sidebarDrag: 'Ziehen zum Umsortieren', sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar', sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen', 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', tabInput: 'Eingabe',
tabUsers: 'Benutzer', tabUsers: 'Benutzer',
shortcutsReset: 'Auf Standard zurücksetzen', shortcutsReset: 'Auf Standard zurücksetzen',
+12 -1
View File
@@ -32,6 +32,7 @@ export const enTranslation = {
expandPlaylists: 'Expand playlists', expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists', collapsePlaylists: 'Collapse playlists',
more: 'More', more: 'More',
feelingLucky: 'Lucky Mix',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
@@ -284,6 +285,8 @@ export const enTranslation = {
mixByTracksDesc: 'Random selection of tracks from your entire library', mixByTracksDesc: 'Random selection of tracks from your entire library',
mixByAlbums: 'Mix by Albums', mixByAlbums: 'Mix by Albums',
mixByAlbumsDesc: 'Random album picks for your next discovery', mixByAlbumsDesc: 'Random album picks for your next discovery',
mixByLucky: 'Lucky Mix',
mixByLuckyDesc: 'Smart instant mix from your top artists, albums, and ratings',
}, },
randomAlbums: { randomAlbums: {
title: 'Random Albums', 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.', 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.', 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: { albums: {
title: 'All Albums', title: 'All Albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -722,6 +731,8 @@ export const enTranslation = {
showChangelogOnUpdate: "Show 'What's New' on update", 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.", showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
randomMixTitle: 'Random Mix Blacklist', 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', 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).', randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add keyword…', randomMixBlacklistPlaceholder: 'Add keyword…',
@@ -757,7 +768,7 @@ export const enTranslation = {
sidebarDrag: 'Drag to reorder', sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible', sidebarFixed: 'Always visible',
randomNavSplitTitle: 'Split Mix navigation', 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', tabInput: 'Input',
tabUsers: 'Users', tabUsers: 'Users',
tabSystem: 'System', tabSystem: 'System',
+12 -1
View File
@@ -32,6 +32,7 @@ export const esTranslation = {
expandPlaylists: 'Expandir listas', expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas', collapsePlaylists: 'Colapsar listas',
more: 'Más', more: 'Más',
feelingLucky: 'Mezcla Suerte',
}, },
home: { home: {
hero: 'Destacado', hero: 'Destacado',
@@ -284,6 +285,8 @@ export const esTranslation = {
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca', mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes', mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento', mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
mixByLucky: 'Mezcla Suerte',
mixByLuckyDesc: 'Instant Mix inteligente con artistas, álbumes y valoraciones destacadas',
}, },
randomAlbums: { randomAlbums: {
title: 'Álbumes Aleatorios', 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.', 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.', 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: { albums: {
title: 'Todos los Álbumes', title: 'Todos los Álbumes',
sortByName: 'AZ (Álbum)', sortByName: 'AZ (Álbum)',
@@ -712,6 +721,8 @@ export const esTranslation = {
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar", 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.', 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', 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
@@ -748,7 +759,7 @@ export const esTranslation = {
sidebarDrag: 'Arrastra para reordenar', sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible', sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix', 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', tabInput: 'Entrada',
tabUsers: 'Usuarios', tabUsers: 'Usuarios',
tabSystem: 'Sistema', tabSystem: 'Sistema',
+12 -1
View File
@@ -31,6 +31,7 @@ export const frTranslation = {
expandPlaylists: 'Développer les playlists', expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists', collapsePlaylists: 'Réduire les playlists',
more: 'Plus', more: 'Plus',
feelingLucky: 'Mix Chance',
}, },
home: { home: {
hero: 'En vedette', hero: 'En vedette',
@@ -283,6 +284,8 @@ export const frTranslation = {
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque', mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
mixByAlbums: 'Mix par albums', mixByAlbums: 'Mix par albums',
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes', 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: { randomAlbums: {
title: 'Albums aléatoires', 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.', 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.', 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: { albums: {
title: 'Tous les albums', title: 'Tous les albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -707,6 +716,8 @@ export const frTranslation = {
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", 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.", 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', 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', 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).', 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é…', randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
@@ -743,7 +754,7 @@ export const frTranslation = {
sidebarDrag: 'Glisser pour réorganiser', sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible', sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix', 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', tabInput: 'Entrée',
tabUsers: 'Utilisateurs', tabUsers: 'Utilisateurs',
shortcutsReset: 'Réinitialiser', shortcutsReset: 'Réinitialiser',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nbTranslation = {
expandPlaylists: 'Utvid spillelister', expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister', collapsePlaylists: 'Skjul spillelister',
more: 'Mer', more: 'Mer',
feelingLucky: 'Lykkemiks',
}, },
home: { home: {
hero: 'Utvalgt', hero: 'Utvalgt',
@@ -283,6 +284,8 @@ export const nbTranslation = {
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt', mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
mixByAlbums: 'Miks etter album', mixByAlbums: 'Miks etter album',
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser', mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
mixByLucky: 'Lykkemiks',
mixByLuckyDesc: 'Smart Instant Mix fra toppartister, album og gode vurderinger',
}, },
randomAlbums: { randomAlbums: {
title: 'Tilfeldige album', 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.', 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.', 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: { albums: {
title: 'Alle album', title: 'Alle album',
sortByName: 'A–Å (Album)', sortByName: 'A–Å (Album)',
@@ -706,6 +715,8 @@ export const nbTranslation = {
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon", 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.", showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
randomMixTitle: 'Svarteliste for tilfeldig miks', 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', 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å).', 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…', randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
@@ -742,7 +753,7 @@ export const nbTranslation = {
sidebarDrag: 'Dra for å endre rekkefølge', sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig', sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon', 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', tabShortcuts: 'Snarveier',
tabUsers: 'Brukere', tabUsers: 'Brukere',
tabSystem: 'System', tabSystem: 'System',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nlTranslation = {
expandPlaylists: 'Afspeellijsten uitklappen', expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen', collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer', more: 'Meer',
feelingLucky: 'Geluksmix',
}, },
home: { home: {
hero: 'Uitgelicht', hero: 'Uitgelicht',
@@ -282,6 +283,8 @@ export const nlTranslation = {
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek', mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
mixByAlbums: 'Mix op albums', mixByAlbums: 'Mix op albums',
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen', mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
mixByLucky: 'Geluksmix',
mixByLuckyDesc: 'Slimme Instant Mix op basis van topartiesten, albums en hoge beoordelingen',
}, },
randomAlbums: { randomAlbums: {
title: 'Willekeurige albums', 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.', 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.', 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: { albums: {
title: 'Alle albums', title: 'Alle albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -706,6 +715,8 @@ export const nlTranslation = {
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", 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.', showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
randomMixTitle: 'Willekeurige mix-blacklist', 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
@@ -742,7 +753,7 @@ export const nlTranslation = {
sidebarDrag: 'Slepen om te herordenen', sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar', sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen', 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', tabInput: 'Invoer',
tabUsers: 'Gebruikers', tabUsers: 'Gebruikers',
shortcutsReset: 'Standaard herstellen', shortcutsReset: 'Standaard herstellen',
+14 -1
View File
@@ -32,6 +32,7 @@ export const ruTranslation = {
expandPlaylists: 'Развернуть плейлисты', expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты', collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё', more: 'Ещё',
feelingLucky: 'Мне повезёт',
}, },
home: { home: {
hero: 'Подборка', hero: 'Подборка',
@@ -297,6 +298,8 @@ export const ruTranslation = {
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки', mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
mixByAlbums: 'Микс по альбомам', mixByAlbums: 'Микс по альбомам',
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий', mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
mixByLucky: 'Мне повезёт',
mixByLuckyDesc: 'Умный Instant Mix из топ-артистов, альбомов и высоких оценок',
}, },
randomAlbums: { randomAlbums: {
title: 'Случайные альбомы', title: 'Случайные альбомы',
@@ -354,6 +357,12 @@ export const ruTranslation = {
genreClickHint: genreClickHint:
'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.', 'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.',
}, },
luckyMix: {
done: '«Мне повезёт» готово: {{count}} треков',
failed: 'Не удалось собрать микс «Мне повезёт». Попробуйте ещё раз.',
unavailable: '«Мне повезёт» недоступен на этом сервере.',
cancelTooltip: 'Отменить сборку микса «Мне повезёт»',
},
albums: { albums: {
title: 'Все альбомы', title: 'Все альбомы',
sortByName: 'А–Я (альбом)', sortByName: 'А–Я (альбом)',
@@ -745,6 +754,9 @@ export const ruTranslation = {
showChangelogOnUpdate: 'Показывать «Что нового» после обновления', showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.', showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
randomMixTitle: 'Чёрный список случайного микса', randomMixTitle: 'Чёрный список случайного микса',
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
luckyMixMenuDesc:
'Включает «Мне повезёт» в «Собрать микс», а при раздельной навигации — отдельным пунктом меню. Видно только при включённом AudioMuse на активном сервере.',
randomMixBlacklistTitle: 'Свои слова-фильтры', randomMixBlacklistTitle: 'Свои слова-фильтры',
randomMixBlacklistDesc: randomMixBlacklistDesc:
'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).', 'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).',
@@ -784,7 +796,8 @@ export const ruTranslation = {
sidebarDrag: 'Перетащите для порядка', sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать', sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса', randomNavSplitTitle: 'Разделить навигацию микса',
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».', randomNavSplitDesc:
'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод', tabInput: 'Ввод',
tabUsers: 'Пользователи', tabUsers: 'Пользователи',
tabSystem: 'Система', tabSystem: 'Система',
+12 -1
View File
@@ -31,6 +31,7 @@ export const zhTranslation = {
expandPlaylists: '展开播放列表', expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表', collapsePlaylists: '收起播放列表',
more: '更多', more: '更多',
feelingLucky: '好运混音',
}, },
home: { home: {
hero: '精选', hero: '精选',
@@ -281,6 +282,8 @@ export const zhTranslation = {
mixByTracksDesc: '从整个媒体库随机选取曲目', mixByTracksDesc: '从整个媒体库随机选取曲目',
mixByAlbums: '按专辑混音', mixByAlbums: '按专辑混音',
mixByAlbumsDesc: '随机选取专辑,探索新音乐', mixByAlbumsDesc: '随机选取专辑,探索新音乐',
mixByLucky: '好运混音',
mixByLuckyDesc: '基于高频艺人、专辑和高评分歌曲的智能 Instant Mix',
}, },
randomAlbums: { randomAlbums: {
title: '随机专辑', title: '随机专辑',
@@ -333,6 +336,12 @@ export const zhTranslation = {
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。', filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。', genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
}, },
luckyMix: {
done: '好运混音已就绪:{{count}} 首',
failed: '生成好运混音失败,请重试。',
unavailable: '当前服务器不支持好运混音。',
cancelTooltip: '取消生成好运混音',
},
albums: { albums: {
title: '全部专辑', title: '全部专辑',
sortByName: '按名称排序 (A-Z)', sortByName: '按名称排序 (A-Z)',
@@ -701,6 +710,8 @@ export const zhTranslation = {
showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。', showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
randomMixTitle: '随机混音黑名单', randomMixTitle: '随机混音黑名单',
luckyMixMenuTitle: '在菜单中显示“好运混音”',
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
randomMixBlacklistTitle: '自定义过滤关键词', randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。', randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
randomMixBlacklistPlaceholder: '添加关键词…', randomMixBlacklistPlaceholder: '添加关键词…',
@@ -737,7 +748,7 @@ export const zhTranslation = {
sidebarDrag: '拖动以重新排序', sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示', sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航', randomNavSplitTitle: '拆分混音导航',
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。', randomNavSplitDesc: '在侧边栏中将随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非创建混音合并入口。',
tabInput: '输入', tabInput: '输入',
tabUsers: '用户', tabUsers: '用户',
tabSystem: '系统', 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 React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { interface MixCard {
icon: React.ElementType; icon: React.ElementType;
@@ -28,11 +29,25 @@ const CARDS: MixCard[] = [
export default function RandomLanding() { export default function RandomLanding() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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 ( return (
<div className="random-landing"> <div className="random-landing">
<div className="random-landing-grid"> <div className="random-landing-grid">
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => ( {cards.map(({ icon: Icon, labelKey, descKey, to }) => (
<button <button
key={to} key={to}
className="mix-pick-card" className="mix-pick-card"
+24 -1
View File
@@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection'; import SettingsSubSection from '../components/SettingsSubSection';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow'; 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'; 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="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={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => ( {AUDIOBOOK_GENRES_DISPLAY.map(genre => (
@@ -4394,11 +4414,14 @@ function SidebarCustomizer() {
itemsRef.current = items; itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode); const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => { const libraryItems = items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; 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 (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true; return true;
}); });
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system'); const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
+5
View File
@@ -138,6 +138,8 @@ interface AuthState {
mixMinRatingAlbum: number; mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */ /** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number; 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). */ /** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>; musicFolders: Array<{ id: string; name: string }>;
@@ -251,6 +253,7 @@ interface AuthState {
setMixMinRatingSong: (v: number) => void; setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void; setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void; setMixMinRatingArtist: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void; setMusicLibraryFilter: (folderId: 'all' | string) => void;
@@ -361,6 +364,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0, mixMinRatingSong: 0,
mixMinRatingAlbum: 0, mixMinRatingAlbum: 0,
mixMinRatingArtist: 0, mixMinRatingArtist: 0,
showLuckyMixMenu: true,
randomNavMode: 'hub', randomNavMode: 'hub',
musicFolders: [], musicFolders: [],
musicLibraryFilterByServer: {}, musicLibraryFilterByServer: {},
@@ -528,6 +532,7 @@ export const useAuthStore = create<AuthState>()(
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }), setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }), setRandomNavMode: (v) => set({ randomNavMode: v }),
setMusicFolders: (folders) => { 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; enqueueAt: (tracks: Track[], insertIndex: number) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void; setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
pruneUpcomingToCurrent: () => void;
clearQueue: () => void; clearQueue: () => void;
isQueueVisible: boolean; isQueueVisible: boolean;
@@ -1364,6 +1366,25 @@ export const usePlayerStore = create<PlayerState>()(
if (!wasPlaying) get().resume(); 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 / resume / togglePlay ──────────────────────────────────────────
pause: () => { pause: () => {
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
+1
View File
@@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomPicker', visible: true }, { id: 'randomPicker', visible: true },
{ id: 'randomMix', visible: true }, { id: 'randomMix', visible: true },
{ id: 'randomAlbums', visible: true }, { id: 'randomAlbums', visible: true },
{ id: 'luckyMix', visible: true },
{ id: 'artists', visible: true }, { id: 'artists', visible: true },
{ id: 'genres', visible: true }, { id: 'genres', visible: true },
{ id: 'favorites', 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); 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 ─ */
.playlist-edit-modal { .playlist-edit-modal {
max-width: 560px; 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; } .device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
/* Pause CSS animations when the window is hidden / minimized /* Pause CSS animations when the window is hidden / minimized
Set via App.tsx on `visibilitychange`. WebView2 on Windows keeps compositing - `data-app-hidden`: App.tsx `visibilitychange` `document.hidden` (all platforms).
infinite CSS animations (mesh-aura, portrait-drift, eq-bounce, track-pulse, - `data-psy-native-hidden`: Rust inject on Tauri `win.hide()` / show (WebView2
led-pulse, ) even when the app is minimized, causing constant GPU use. often keeps `document.hidden === false` when the native window is hidden).
Pausing them cuts that to near zero while hidden without touching the Either flag pauses infinite animations including portaled nodes and ::pseudo,
running Rust/audio threads. */ without touching Rust/audio threads. */
/* What's New banner + page /* What's New banner + page
Fixed neutral palette so it reads identically on every theme (light and 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 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 { .mini-player__volume-popover {
position: absolute; /* Positioned via inline `style` (createPortal'd to document.body, so
top: calc(100% + 6px); `position: fixed` + computed left/top from the trigger button's rect).
left: 50%; Falls back gracefully if inline style is missing. */
transform: translateX(-50%); position: fixed;
z-index: 50; z-index: 99998;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: 8px; border-radius: 8px;
@@ -10268,7 +10378,10 @@ html.no-compositing .fs-seekbar-played {
html[data-app-hidden="true"] *, html[data-app-hidden="true"] *,
html[data-app-hidden="true"] *::before, 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; 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 { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */ /** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string { export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
@@ -43,11 +44,15 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
: null; : null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null; const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500); const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [deadlineMs]); }, [deadlineMs, windowHidden]);
if (mode == null || deadlineMs == null) return null; if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode }; return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
} }
+1 -1
View File
@@ -15,7 +15,7 @@ export function getLibraryItemsForReorder(
): SidebarItemConfig[] { ): SidebarItemConfig[] {
return items.filter(cfg => { return items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; 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 (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true; return true;
}); });
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare global {
interface Window {
__psyHidden?: boolean;
}
}
export {};