mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge branch 'main' into exp/orbit
This commit is contained in:
+17
-3
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,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' },
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
|
||||||
|
*
|
||||||
|
* Single source of truth for the gate — previously this logic was inlined in
|
||||||
|
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
|
||||||
|
* the sidebarNavReorder filter. All call sites share the same three-way
|
||||||
|
* predicate:
|
||||||
|
* 1. User hasn't hidden it via the Settings toggle.
|
||||||
|
* 2. AudioMuse is enabled for the active server (feature depends on
|
||||||
|
* audiomuse-backed similar-track quality).
|
||||||
|
* 3. An active server exists at all.
|
||||||
|
*
|
||||||
|
* Callers that additionally care about the "split vs hub" navigation mode
|
||||||
|
* should combine this with `randomNavMode === 'separate'` explicitly — that's
|
||||||
|
* an orthogonal UI placement concern, not an availability concern.
|
||||||
|
*/
|
||||||
|
export function isLuckyMixAvailable(args: {
|
||||||
|
activeServerId: string | null | undefined;
|
||||||
|
audiomuseByServer: Record<string, boolean>;
|
||||||
|
showLuckyMixMenu: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
|
||||||
|
if (!showLuckyMixMenu) return false;
|
||||||
|
if (!activeServerId) return false;
|
||||||
|
return Boolean(audiomuseByServer[activeServerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook form — subscribes to the three authStore slices the predicate
|
||||||
|
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
|
||||||
|
* toggle on/off) re-renders the caller automatically.
|
||||||
|
*/
|
||||||
|
export function useLuckyMixAvailable(): boolean {
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
|
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||||
|
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||||
|
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const WindowVisibilityContext = createContext(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether the Tauri window is hidden.
|
||||||
|
*
|
||||||
|
* On Windows WebView2, `visibilitychange` and `blur`/`focus` events do not
|
||||||
|
* fire when `win.hide()` is called. We fall back to polling `document.hidden`
|
||||||
|
* OR-ed with `window.__psyHidden` (set from Rust before/after `win.hide()` /
|
||||||
|
* `show()`) — the latter is the reliable signal on WebView2 where
|
||||||
|
* `document.hidden` may stay false. Adaptive interval: slow while hidden
|
||||||
|
* (minimize wakeups), 500 ms while visible (catch show without burning CPU).
|
||||||
|
*/
|
||||||
|
function isWindowHidden() {
|
||||||
|
return document.hidden || !!window.__psyHidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowVisibilityProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [hidden, setHidden] = useState(isWindowHidden);
|
||||||
|
const hiddenRef = useRef(hidden);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hiddenRef.current = isWindowHidden();
|
||||||
|
let cancelled = false;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const interval = hiddenRef.current ? 1000 : 500;
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timeoutId = null;
|
||||||
|
if (cancelled) return;
|
||||||
|
const current = isWindowHidden();
|
||||||
|
if (current !== hiddenRef.current) {
|
||||||
|
hiddenRef.current = current;
|
||||||
|
setHidden(current);
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
}, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WindowVisibilityContext.Provider value={hidden}>
|
||||||
|
{children}
|
||||||
|
</WindowVisibilityContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWindowVisibility() {
|
||||||
|
return useContext(WindowVisibilityContext);
|
||||||
|
}
|
||||||
+12
-1
@@ -31,6 +31,7 @@ export const deTranslation = {
|
|||||||
expandPlaylists: 'Playlists ausklappen',
|
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: 'A–Z (Album)',
|
sortByName: 'A–Z (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
@@ -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: 'A–Z (Album)',
|
sortByName: 'A–Z (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
@@ -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: 'A–Z (Álbum)',
|
sortByName: 'A–Z (Á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
@@ -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: 'A–Z (Album)',
|
sortByName: 'A–Z (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
@@ -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
@@ -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: 'A–Z (Album)',
|
sortByName: 'A–Z (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
@@ -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
@@ -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: '系统',
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { buildAndPlayLuckyMix } from '../utils/luckyMix';
|
||||||
|
|
||||||
|
export default function LuckyMixPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedRef.current) return;
|
||||||
|
startedRef.current = true;
|
||||||
|
void buildAndPlayLuckyMix();
|
||||||
|
navigate('/now-playing', { replace: true });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import 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
@@ -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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface LuckyMixState {
|
||||||
|
/** True while `buildAndPlayLuckyMix` is actively assembling a mix. */
|
||||||
|
isRolling: boolean;
|
||||||
|
/**
|
||||||
|
* Set by `cancel()` — the build loop polls this between awaits and bails
|
||||||
|
* out silently when true. Reset to false on `start()` so a new build can
|
||||||
|
* run after a cancelled one.
|
||||||
|
*/
|
||||||
|
cancelRequested: boolean;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLuckyMixStore = create<LuckyMixState>((set) => ({
|
||||||
|
isRolling: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
start: () => set({ isRolling: true, cancelRequested: false }),
|
||||||
|
stop: () => set({ isRolling: false, cancelRequested: false }),
|
||||||
|
cancel: () => set({ cancelRequested: true }),
|
||||||
|
}));
|
||||||
@@ -197,6 +197,8 @@ interface PlayerState {
|
|||||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import {
|
||||||
|
filterSongsToActiveLibrary,
|
||||||
|
getAlbum,
|
||||||
|
getAlbumList,
|
||||||
|
getRandomSongs,
|
||||||
|
getSimilarSongs,
|
||||||
|
getTopSongs,
|
||||||
|
type SubsonicAlbum,
|
||||||
|
type SubsonicSong,
|
||||||
|
} from '../api/subsonic';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { songToTrack, usePlayerStore, type Track } from '../store/playerStore';
|
||||||
|
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||||
|
import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||||
|
import { showToast } from './toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
||||||
|
* flips to true. The `catch` handler swallows it silently (no toast, no
|
||||||
|
* queue restore, no error state) — the user already moved on.
|
||||||
|
*/
|
||||||
|
class LuckyMixCancelled extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('lucky-mix-cancelled');
|
||||||
|
this.name = 'LuckyMixCancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopArtist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
totalPlays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOST_PLAYED_PAGE_SIZE = 100;
|
||||||
|
const MOST_PLAYED_MAX_ALBUMS = 500;
|
||||||
|
const MIX_TARGET_SIZE = 50;
|
||||||
|
const SEED_TARGET_SIZE = 15;
|
||||||
|
|
||||||
|
function sampleRandom<T>(items: T[], count: number): T[] {
|
||||||
|
if (count <= 0 || items.length === 0) return [];
|
||||||
|
const arr = [...items];
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr.slice(0, Math.min(count, arr.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
||||||
|
const out: SubsonicSong[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const s of items) {
|
||||||
|
if (!s?.id || seen.has(s.id)) continue;
|
||||||
|
seen.add(s.id);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
||||||
|
return uniqueBySongId([...base, ...incoming]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
||||||
|
const map = new Map<string, TopArtist>();
|
||||||
|
for (const a of albums) {
|
||||||
|
const plays = a.playCount ?? 0;
|
||||||
|
if (!a.artistId || !a.artist || plays <= 0) continue;
|
||||||
|
const prev = map.get(a.artistId);
|
||||||
|
if (prev) {
|
||||||
|
prev.totalPlays += plays;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
||||||
|
const out: SubsonicAlbum[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
||||||
|
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
||||||
|
if (!page.length) break;
|
||||||
|
out.push(...page);
|
||||||
|
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
||||||
|
offset += MOST_PLAYED_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickSongsForArtist(artist: TopArtist, need: number): Promise<SubsonicSong[]> {
|
||||||
|
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
||||||
|
if (primary.length >= need) return sampleRandom(primary, need);
|
||||||
|
|
||||||
|
const extra: SubsonicSong[] = [];
|
||||||
|
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) {
|
||||||
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||||
|
for (const s of rnd) {
|
||||||
|
if (s.artistId === artist.id || s.artist === artist.name) {
|
||||||
|
extra.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sampleRandom(uniqueBySongId([...primary, ...extra]), need);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickSongsForAlbum(albumId: string, need: number): Promise<SubsonicSong[]> {
|
||||||
|
const full = await getAlbum(albumId).catch(() => null);
|
||||||
|
if (!full?.songs?.length) return [];
|
||||||
|
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||||
|
return sampleRandom(uniqueBySongId(scopedSongs), need);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promise<SubsonicSong[]> {
|
||||||
|
const out: SubsonicSong[] = [];
|
||||||
|
const push = (s: SubsonicSong) => {
|
||||||
|
const r = s.userRating ?? 0;
|
||||||
|
if (r < 4) return;
|
||||||
|
if (existingIds.has(s.id)) return;
|
||||||
|
if (out.some(x => x.id === s.id)) return;
|
||||||
|
out.push(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 14 && out.length < need; i++) {
|
||||||
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||||
|
rnd.forEach(push);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampleRandom(out, need);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||||
|
const lucky = useLuckyMixStore.getState();
|
||||||
|
if (lucky.isRolling) return;
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
const debugEnabled = auth.loggingMode === 'debug';
|
||||||
|
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
||||||
|
const logStep = (step: string, details?: unknown) => {
|
||||||
|
if (!debugEnabled) return;
|
||||||
|
const payload = { step, details };
|
||||||
|
debugSteps.push(payload);
|
||||||
|
console.debug('[psysonic][lucky-mix]', payload);
|
||||||
|
void invoke('frontend_debug_log', {
|
||||||
|
scope: 'lucky-mix',
|
||||||
|
message: JSON.stringify(payload),
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
const songDebug = (songs: SubsonicSong[]) =>
|
||||||
|
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
||||||
|
const albumDebug = (albums: SubsonicAlbum[]) =>
|
||||||
|
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
|
||||||
|
const activeServerId = auth.activeServerId;
|
||||||
|
const available = isLuckyMixAvailable({
|
||||||
|
activeServerId,
|
||||||
|
audiomuseByServer: auth.audiomuseNavidromeByServer,
|
||||||
|
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||||
|
});
|
||||||
|
logStep('init', {
|
||||||
|
activeServerId,
|
||||||
|
available,
|
||||||
|
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||||
|
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
||||||
|
});
|
||||||
|
if (!available) {
|
||||||
|
logStep('abort_unavailable');
|
||||||
|
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the current queue *before* we prune — so if the build fails
|
||||||
|
// before we ever play a track, we can put it back the way it was instead
|
||||||
|
// of leaving the user with an empty player.
|
||||||
|
const playerStateBefore = usePlayerStore.getState();
|
||||||
|
const queueSnapshot: { queue: Track[]; queueIndex: number } = {
|
||||||
|
queue: [...playerStateBefore.queue],
|
||||||
|
queueIndex: playerStateBefore.queueIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
|
||||||
|
// next tracks while the mix is still building (first playTrack may be delayed).
|
||||||
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||||
|
|
||||||
|
lucky.start();
|
||||||
|
// Per-run handles. Live outside the try so `finally`/`catch` can read
|
||||||
|
// `startedPlayback` (drives the queue-restore decision) and clean up the
|
||||||
|
// player-store subscription unconditionally.
|
||||||
|
let unsubPlayer: (() => void) | null = null;
|
||||||
|
let startedPlayback = false;
|
||||||
|
try {
|
||||||
|
const queuedIds = new Set<string>();
|
||||||
|
let allSeedSongs: SubsonicSong[] = [];
|
||||||
|
|
||||||
|
const bailIfCancelled = () => {
|
||||||
|
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
||||||
|
};
|
||||||
|
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
|
||||||
|
const isBlockedByRating = (song: SubsonicSong) => {
|
||||||
|
const rating = song.userRating ?? 0;
|
||||||
|
return rating === 1 || rating === 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startImmediatePlayback = (song: SubsonicSong, source: string) => {
|
||||||
|
if (startedPlayback || !song?.id || isBlockedByRating(song)) return;
|
||||||
|
startedPlayback = true;
|
||||||
|
queuedIds.add(song.id);
|
||||||
|
const track = songToTrack(song);
|
||||||
|
usePlayerStore.getState().playTrack(track, [track], true);
|
||||||
|
logStep('start_immediate_playback', {
|
||||||
|
source,
|
||||||
|
song: songDebug([song])[0],
|
||||||
|
queuedCount: queuedIds.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-cancel: once we're playing, watch the player store. If the
|
||||||
|
// current track switches to something the user picked themselves (not
|
||||||
|
// in our queuedIds set), treat that as "user moved on" and cancel the
|
||||||
|
// build so we don't later overwrite their choice with our finalised mix.
|
||||||
|
if (!unsubPlayer) {
|
||||||
|
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
|
||||||
|
const prevId = prev.currentTrack?.id ?? null;
|
||||||
|
const nextId = state.currentTrack?.id ?? null;
|
||||||
|
if (nextId === prevId) return;
|
||||||
|
if (!nextId) return;
|
||||||
|
if (queuedIds.has(nextId)) return;
|
||||||
|
useLuckyMixStore.getState().cancel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
|
||||||
|
if (useLuckyMixStore.getState().cancelRequested) return 0;
|
||||||
|
if (reachedTarget()) return 0;
|
||||||
|
if (!songs.length) return 0;
|
||||||
|
const deduped = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id) && !isBlockedByRating(s));
|
||||||
|
if (!deduped.length) return 0;
|
||||||
|
|
||||||
|
const candidates = [...deduped];
|
||||||
|
if (!startedPlayback && candidates.length > 0) {
|
||||||
|
const first = candidates.shift();
|
||||||
|
if (first) startImmediatePlayback(first, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates.length) return 0;
|
||||||
|
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
|
||||||
|
if (remaining <= 0) return 0;
|
||||||
|
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||||
|
if (!toAdd.length) return 0;
|
||||||
|
toAdd.forEach(s => queuedIds.add(s.id));
|
||||||
|
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
|
||||||
|
logStep('append_queue_batch', {
|
||||||
|
reason,
|
||||||
|
added: toAdd.length,
|
||||||
|
queuedCount: queuedIds.size,
|
||||||
|
songs: songDebug(toAdd),
|
||||||
|
});
|
||||||
|
return toAdd.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||||
|
bailIfCancelled();
|
||||||
|
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||||
|
logStep('fetch_frequent_albums', {
|
||||||
|
fetched: frequentAlbums.length,
|
||||||
|
withPlays: albumsWithPlays.length,
|
||||||
|
});
|
||||||
|
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
|
||||||
|
const pickedArtists = sampleRandom(topArtists, 2);
|
||||||
|
logStep('pick_top_artists', {
|
||||||
|
topArtistsCount: topArtists.length,
|
||||||
|
pickedArtists,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const artist of pickedArtists) {
|
||||||
|
bailIfCancelled();
|
||||||
|
const songs = await pickSongsForArtist(artist, 3);
|
||||||
|
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||||
|
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||||
|
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
||||||
|
logStep('pick_artist_songs', {
|
||||||
|
artist,
|
||||||
|
pickedCount: songs.length,
|
||||||
|
songs: songDebug(songs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
||||||
|
logStep('pick_top_albums', {
|
||||||
|
poolCount: albumsWithPlays.length,
|
||||||
|
pickedAlbums: albumDebug(pickedAlbums),
|
||||||
|
});
|
||||||
|
for (const album of pickedAlbums) {
|
||||||
|
bailIfCancelled();
|
||||||
|
const songs = await pickSongsForAlbum(album.id, 3);
|
||||||
|
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||||
|
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||||
|
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
||||||
|
logStep('pick_album_songs', {
|
||||||
|
albumId: album.id,
|
||||||
|
pickedCount: songs.length,
|
||||||
|
songs: songDebug(songs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bailIfCancelled();
|
||||||
|
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
|
||||||
|
logStep('pick_rated_songs_4plus_only', {
|
||||||
|
ratedPickedCount: rated.length,
|
||||||
|
ratedSongs: songDebug(rated),
|
||||||
|
});
|
||||||
|
allSeedSongs = uniqueAppend(allSeedSongs, rated);
|
||||||
|
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s));
|
||||||
|
logStep('seed_after_dedup', {
|
||||||
|
seedCount: seeds.length,
|
||||||
|
seeds: songDebug(seeds),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seeds.length < SEED_TARGET_SIZE) {
|
||||||
|
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
|
||||||
|
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
|
||||||
|
bailIfCancelled();
|
||||||
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
|
||||||
|
const allowedRnd = rnd.filter(s => !isBlockedByRating(s));
|
||||||
|
seeds = uniqueAppend(seeds, allowedRnd);
|
||||||
|
const firstPlayable = allowedRnd[0];
|
||||||
|
if (firstPlayable) startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
||||||
|
logStep('seed_fill_batch', {
|
||||||
|
batch: i + 1,
|
||||||
|
fetched: rnd.length,
|
||||||
|
seedCount: seeds.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
||||||
|
logStep('seed_fill_end', {
|
||||||
|
finalSeedCount: seeds.length,
|
||||||
|
seeds: songDebug(seeds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seeds.length === 0) {
|
||||||
|
throw new Error('no-seeds');
|
||||||
|
}
|
||||||
|
if (!startedPlayback) {
|
||||||
|
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s));
|
||||||
|
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
||||||
|
}
|
||||||
|
|
||||||
|
let similarRaw: SubsonicSong[] = [];
|
||||||
|
let similar: SubsonicSong[] = [];
|
||||||
|
for (let i = 0; i < seeds.length; i++) {
|
||||||
|
bailIfCancelled();
|
||||||
|
const seed = seeds[i];
|
||||||
|
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
|
||||||
|
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
|
||||||
|
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
||||||
|
similar = uniqueAppend(similar, oneScoped);
|
||||||
|
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
|
||||||
|
if (reachedTarget()) break;
|
||||||
|
}
|
||||||
|
const seedForPool = seeds.filter(() => Math.random() < 0.5);
|
||||||
|
let pool = uniqueBySongId([...seedForPool, ...similar]);
|
||||||
|
appendSongsToQueue(seedForPool, 'seed-50pct');
|
||||||
|
logStep('instant_mix', {
|
||||||
|
seedUsedForInstantMixCount: seeds.length,
|
||||||
|
seedIncludedInPoolCount: seedForPool.length,
|
||||||
|
seedIncludedInPool: songDebug(seedForPool),
|
||||||
|
similarRawCount: similarRaw.length,
|
||||||
|
similarScopedCount: similar.length,
|
||||||
|
initialPoolCount: pool.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
|
||||||
|
bailIfCancelled();
|
||||||
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||||
|
pool = uniqueAppend(pool, rnd);
|
||||||
|
appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
|
||||||
|
logStep('pool_fill_batch', {
|
||||||
|
batch: i + 1,
|
||||||
|
fetched: rnd.length,
|
||||||
|
poolCount: pool.length,
|
||||||
|
});
|
||||||
|
if (reachedTarget()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bailIfCancelled();
|
||||||
|
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
|
||||||
|
appendSongsToQueue(finalSongs, 'finalize-randomized');
|
||||||
|
logStep('final_queue_state', {
|
||||||
|
poolCount: pool.length,
|
||||||
|
queuedCount: queuedIds.size,
|
||||||
|
queuedTarget: MIX_TARGET_SIZE,
|
||||||
|
});
|
||||||
|
if (queuedIds.size === 0) {
|
||||||
|
throw new Error('empty-mix');
|
||||||
|
}
|
||||||
|
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
|
||||||
|
logStep('done', { queueCount: queuedIds.size });
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||||
|
void invoke('frontend_debug_log', {
|
||||||
|
scope: 'lucky-mix',
|
||||||
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Cancellation is a user-initiated path, not an error. Silent teardown.
|
||||||
|
if (err instanceof LuckyMixCancelled) {
|
||||||
|
logStep('cancelled');
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||||
|
void invoke('frontend_debug_log', {
|
||||||
|
scope: 'lucky-mix',
|
||||||
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[psysonic] lucky mix failed:', err);
|
||||||
|
logStep('failed', { error: String(err) });
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||||
|
void invoke('frontend_debug_log', {
|
||||||
|
scope: 'lucky-mix',
|
||||||
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// If we failed before ever calling playTrack, the queue-prune we did up
|
||||||
|
// front left the user with nothing. Restore the snapshot so they land
|
||||||
|
// back where they were pre-click instead of in an empty player.
|
||||||
|
// If playback did start, leave it alone — their current track plus
|
||||||
|
// whatever we managed to enqueue is more useful than the old queue.
|
||||||
|
if (!startedPlayback) {
|
||||||
|
usePlayerStore.setState({
|
||||||
|
queue: queueSnapshot.queue,
|
||||||
|
queueIndex: queueSnapshot.queueIndex,
|
||||||
|
});
|
||||||
|
logStep('queue_restored_after_failure', {
|
||||||
|
restoredCount: queueSnapshot.queue.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||||
|
} finally {
|
||||||
|
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
||||||
|
useLuckyMixStore.getState().stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+8
@@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__psyHidden?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|||||||
Reference in New Issue
Block a user