app_lib/
tray_menu.rs

1//! System tray / menu bar icon: playback controls, dynamic title + tooltip, popup menu,
2//! and (non-Linux) a **WebView popover** styled like macOS Now Playing (no artwork).
3use std::collections::HashMap;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Mutex;
6use std::thread;
7use std::time::Duration;
8use tauri::image::Image;
9use tauri::menu::MenuBuilder;
10use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
11use tauri::{
12    App, AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, Position, Rect,
13    Size, State, Wry,
14};
15
16use crate::history;
17
18/// Max characters for the first row of the tray dropdown (macOS truncates visually; keep readable).
19const TRAY_MENU_NOW_PLAYING_MAX: usize = 96;
20
21const TRAY_POPOVER_W: u32 = 340;
22/// Default height until JS measures `#shell` (`tray_popover_resize`); generous so first paint is not clipped
23/// (multi-line title + meta + directory path + progress + volume + speed + transport + padding).
24const TRAY_POPOVER_H: u32 = 480;
25
26/// Set `AUDIO_HAXOR_TRAY_DEBUG=1` in the environment to print every successful `tray-popover-state` /
27/// `tray-popover-ui-theme` emit to stderr (state includes the ~500 ms host poll). Emit **failures** always log.
28fn emit_tray_popover_state(app: &AppHandle<Wry>, emit: &TrayPopoverEmit) {
29    let appearance_n = emit
30        .appearance
31        .as_ref()
32        .map(|m| m.len())
33        .unwrap_or(0);
34    match app.emit_to("tray-popover", "tray-popover-state", emit) {
35        Ok(()) => {
36            if std::env::var_os("AUDIO_HAXOR_TRAY_DEBUG").is_some() {
37                eprintln!(
38                    "[tray-popover-host] emit tray-popover-state ok idle={} ui_theme={} appearance_vars={} title_ch={} subtitle_ch={} playing={} elapsed={:.2} total_sec={:?}",
39                    emit.idle,
40                    emit.ui_theme,
41                    appearance_n,
42                    emit.title.chars().count(),
43                    emit.subtitle.chars().count(),
44                    emit.playing,
45                    emit.elapsed_sec,
46                    emit.total_sec
47                );
48            }
49        }
50        Err(e) => {
51            eprintln!("[tray-popover-host] emit tray-popover-state FAILED: {e}");
52        }
53    }
54}
55
56/// Light/dark from prefs (`prefs_set` key `theme`). Same debug env / failure logging as [`emit_tray_popover_state`].
57pub fn emit_tray_popover_ui_theme(app: &AppHandle<Wry>, ui_theme: &str) {
58    let payload = serde_json::json!({ "ui_theme": ui_theme });
59    match app.emit_to("tray-popover", "tray-popover-ui-theme", payload) {
60        Ok(()) => {
61            if std::env::var_os("AUDIO_HAXOR_TRAY_DEBUG").is_some() {
62                eprintln!(
63                    "[tray-popover-host] emit tray-popover-ui-theme ok ui_theme={ui_theme}"
64                );
65            }
66        }
67        Err(e) => {
68            eprintln!("[tray-popover-host] emit tray-popover-ui-theme FAILED: {e}");
69        }
70    }
71}
72
73/// Prefer the bundle window icon; otherwise embed `32x32.png` so dev/release always have pixels.
74fn tray_menu_bar_icon(app: &App) -> tauri::Result<Image<'static>> {
75    if let Some(icon) = app.default_window_icon() {
76        return Ok(icon.clone().to_owned());
77    }
78    const TRAY_PNG: &[u8] = include_bytes!("../icons/32x32.png");
79    Image::from_bytes(TRAY_PNG)
80}
81
82fn t(strings: &HashMap<String, String>, key: &str, fallback: &str) -> String {
83    strings
84        .get(key)
85        .map(|s| s.as_str())
86        .filter(|s| !s.is_empty())
87        .unwrap_or(fallback)
88        .to_string()
89}
90
91/// Prefs key `theme` → popover `data-theme` (`light` vs `dark` HUD).
92fn tray_popover_ui_theme_from_prefs() -> String {
93    match history::get_preference("theme") {
94        Some(serde_json::Value::String(s)) if s == "light" => "light".to_string(),
95        _ => "dark".to_string(),
96    }
97}
98
99fn truncate_tray_menu_line(s: &str) -> String {
100    let t = s.trim();
101    if t.chars().count() <= TRAY_MENU_NOW_PLAYING_MAX {
102        return t.to_string();
103    }
104    let mut out = String::new();
105    for (i, ch) in t.chars().enumerate() {
106        if i >= TRAY_MENU_NOW_PLAYING_MAX.saturating_sub(1) {
107            break;
108        }
109        out.push(ch);
110    }
111    out.push('…');
112    out
113}
114
115/// Serialized to the `tray-popover` WebView for frosted Now Playing UI.
116#[derive(Clone, serde::Serialize)]
117pub struct TrayPopoverEmit {
118    pub idle: bool,
119    pub title: String,
120    pub subtitle: String,
121    /// Absolute path of the playing file — tray popover reveal / copy / context menu.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub reveal_path: Option<String>,
124    pub elapsed_sec: f64,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub total_sec: Option<f64>,
127    pub playing: bool,
128    /// Clamped 0.25..=2.0 — mirrors prefs `audioSpeed` / main `#npSpeed`.
129    pub playback_speed: f64,
130    /// 0..=100 — mirrors prefs `audioVolume` / main `#npVolume`.
131    pub volume_pct: u8,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub idle_hint: Option<String>,
134    /// `"light"` or `"dark"` — tray-popover.html uses this for `html[data-theme]`.
135    pub ui_theme: String,
136    /// Main-window scheme snapshot (`--cyan`, `--bg-primary`, …) applied on the popover root.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub appearance: Option<HashMap<String, String>>,
139}
140
141/// Per-tray state: icon + cached i18n for rebuilding the popup without hitting SQLite each tick.
142pub struct TrayState {
143    pub inner: Mutex<TrayStateInner>,
144}
145
146pub struct TrayStateInner {
147    pub tray: Option<TrayIcon<Wry>>,
148    pub menu_strings: HashMap<String, String>,
149    pub now_playing_menu_line: Option<String>,
150    pub last_popover_emit: Option<TrayPopoverEmit>,
151    pub last_tray_appearance: Option<HashMap<String, String>>,
152}
153
154impl Default for TrayStateInner {
155    fn default() -> Self {
156        Self {
157            tray: None,
158            menu_strings: HashMap::new(),
159            now_playing_menu_line: None,
160            last_popover_emit: None,
161            last_tray_appearance: None,
162        }
163    }
164}
165
166impl Default for TrayState {
167    fn default() -> Self {
168        Self {
169            inner: Mutex::new(TrayStateInner::default()),
170        }
171    }
172}
173
174/// Rebuild tray popup labels from `app_i18n` (after UI locale change). Preserves last now-playing line.
175pub fn refresh_tray_popup_menu(
176    app: &AppHandle<Wry>,
177    state: &TrayState,
178    strings: &HashMap<String, String>,
179) -> Result<(), String> {
180    let mut guard = state
181        .inner
182        .lock()
183        .map_err(|_| "tray state mutex poisoned".to_string())?;
184    let Some(tray) = guard.tray.clone() else {
185        return Ok(());
186    };
187    guard.menu_strings.clone_from(strings);
188    let menu = build_tray_popup_menu(
189        app,
190        &guard.menu_strings,
191        guard.now_playing_menu_line.as_deref(),
192    )?;
193    drop(guard);
194    tray.set_menu(Some(menu)).map_err(|e| e.to_string())
195}
196
197fn build_tray_popup_menu(
198    app: &AppHandle<Wry>,
199    strings: &HashMap<String, String>,
200    now_playing_line: Option<&str>,
201) -> Result<tauri::menu::Menu<Wry>, String> {
202    let mut b = MenuBuilder::new(app);
203    if let Some(raw) = now_playing_line {
204        let line = truncate_tray_menu_line(raw);
205        if !line.is_empty() {
206            b = b.text("tray_now_playing", line);
207            b = b.separator();
208        }
209    }
210    b.text("tray_show", t(strings, "tray.show", "Show AUDIO_HAXOR"))
211        .separator()
212        .text("tray_scan_all", t(strings, "tray.scan_all", "Scan All"))
213        .text("tray_stop_all", t(strings, "tray.stop_all", "Stop All"))
214        .separator()
215        .text("tray_prev", t(strings, "tray.previous_track", "Previous Track"))
216        .text("tray_play_pause", t(strings, "tray.play_pause", "Play / Pause"))
217        .text("tray_next", t(strings, "tray.next_track", "Next Track"))
218        .separator()
219        .text("tray_quit", t(strings, "tray.quit", "Quit"))
220        .build()
221        .map_err(|e| e.to_string())
222}
223
224/// `scale_factor` maps logical popover width to **physical** pixels when `rect` uses physical coordinates
225/// (common on macOS tray events).
226fn popover_xy_below_tray(rect: &Rect, scale_factor: f64) -> (i32, i32) {
227    let physical_coords = matches!(rect.position, Position::Physical(..));
228    let pop_w_half = if physical_coords {
229        f64::from(TRAY_POPOVER_W) * scale_factor / 2.0
230    } else {
231        f64::from(TRAY_POPOVER_W) / 2.0
232    };
233    let gap = if physical_coords {
234        4.0_f64 * scale_factor
235    } else {
236        4.0_f64
237    };
238    let (px, py) = match rect.position {
239        Position::Physical(p) => (p.x as f64, p.y as f64),
240        Position::Logical(p) => (p.x, p.y),
241    };
242    let (w, h) = match rect.size {
243        Size::Physical(s) => (s.width as f64, s.height as f64),
244        Size::Logical(s) => (s.width, s.height),
245    };
246    let x = px + w / 2.0 - pop_w_half;
247    let y = py + h + gap;
248    (x.floor() as i32, y.floor() as i32)
249}
250
251fn toggle_tray_popover(app: &AppHandle<Wry>, rect: &Rect) -> Result<(), String> {
252    let tray_state = app.state::<TrayState>();
253    let last = tray_state
254        .inner
255        .lock()
256        .map_err(|_| "tray state mutex poisoned".to_string())?
257        .last_popover_emit
258        .clone();
259    let Some(win) = app.get_webview_window("tray-popover") else {
260        return Ok(());
261    };
262    if win.is_visible().unwrap_or(false) {
263        let _ = win.hide();
264        return Ok(());
265    }
266    let mut emit = last.unwrap_or(TrayPopoverEmit {
267        idle: true,
268        title: String::new(),
269        subtitle: String::new(),
270        reveal_path: None,
271        elapsed_sec: 0.0,
272        total_sec: None,
273        playing: false,
274        playback_speed: 1.0,
275        volume_pct: 100,
276        idle_hint: None,
277        ui_theme: tray_popover_ui_theme_from_prefs(),
278        appearance: None,
279    });
280    emit.ui_theme = tray_popover_ui_theme_from_prefs();
281    emit_tray_popover_state(app, &emit);
282    let scale = win.scale_factor().unwrap_or(1.0);
283    let (mut x, y) = popover_xy_below_tray(rect, scale);
284    x = x.max(8);
285    let _ = win.set_size(tauri::Size::Logical(LogicalSize::new(
286        f64::from(TRAY_POPOVER_W),
287        f64::from(TRAY_POPOVER_H),
288    )));
289    let _ = win.set_position(tauri::Position::Physical(PhysicalPosition::new(x, y)));
290    let _ = win.show();
291    /* Re-apply after `show`: some platforms drop window level across `hide`/`show` cycles. */
292    let _ = win.set_always_on_top(true);
293    /* Force the popover to become the key window so keyboard events (Escape) reach its JS
294     * `keydown` listener AND so `WindowEvent::Focused(false)` fires when the user clicks
295     * outside. NSPanel with `visibleOnAllWorkspaces` defaults to non-activating — clicks only
296     * transfer "active" status, not key status — so without this call the popover never gets
297     * keyboard focus and never fires a blur event either. The historical comment said
298     * `set_focus` causes Mission Control to jump Spaces, but that was about focusing the
299     * main window; the popover itself is `visibleOnAllWorkspaces: true` so focusing it stays
300     * on the current Space. If this turns out to Space-jump in practice we can hop to an
301     * Objective-C `makeKeyWindow` via FFI that skips the `NSApp.activate` step. */
302    let _ = win.set_focus();
303    Ok(())
304}
305
306pub fn create_tray(app: &App, strings: &HashMap<String, String>) -> Result<TrayIcon<Wry>, String> {
307    let handle = app.handle().clone();
308    let tray_menu = build_tray_popup_menu(&handle, strings, None)?;
309    let icon = tray_menu_bar_icon(app).map_err(|e| e.to_string())?;
310    let mut builder = TrayIconBuilder::new()
311        .menu(&tray_menu)
312        .icon(icon)
313        .tooltip(t(strings, "tray.tooltip", "AUDIO_HAXOR"))
314        .show_menu_on_left_click(cfg!(target_os = "linux"));
315    #[cfg(target_os = "macos")]
316    {
317        // Menu bar PNGs from the app bundle are full-color; `template=true` often draws them invisible.
318        builder = builder.icon_as_template(false);
319    }
320    let tray = builder.build(app).map_err(|e| e.to_string())?;
321    #[cfg(not(target_os = "linux"))]
322    {
323        tray.on_tray_icon_event(move |tray, event| {
324            if let TrayIconEvent::Click {
325                button: MouseButton::Left,
326                button_state: MouseButtonState::Up,
327                rect,
328                ..
329            } = event
330            {
331                let app = tray.app_handle().clone();
332                let _ = toggle_tray_popover(&app, &rect);
333            }
334        });
335    }
336    Ok(tray)
337}
338
339#[derive(serde::Deserialize)]
340pub struct TrayNowPlayingPayload {
341    #[serde(default)]
342    pub title_bar: Option<String>,
343    pub tooltip: String,
344    #[serde(default)]
345    pub idle: bool,
346    #[serde(default)]
347    pub popover_title: Option<String>,
348    #[serde(default)]
349    pub popover_subtitle: Option<String>,
350    #[serde(default)]
351    pub elapsed_sec: Option<f64>,
352    #[serde(default)]
353    pub total_sec: Option<f64>,
354    #[serde(default)]
355    pub popover_playing: Option<bool>,
356    #[serde(default)]
357    pub popover_idle_label: Option<String>,
358    /// Optional: main prefs `audioSpeed` (0.25..=2). When omitted, last popover value is kept.
359    #[serde(default)]
360    pub playback_speed: Option<f64>,
361    /// Optional: main prefs `audioVolume` (0..=100). When omitted, last popover value is kept.
362    #[serde(default)]
363    pub volume_pct: Option<f64>,
364    /// Optional: main window `data-theme` (`"light"` / `"dark"`). When omitted, Rust reads prefs.
365    #[serde(default)]
366    pub ui_theme: Option<String>,
367    /// Optional: `getComputedStyle(document.documentElement)` snapshot for scheme vars (`--cyan`, …).
368    #[serde(default)]
369    pub appearance: Option<HashMap<String, String>>,
370    /// Optional: filesystem path for the playing item — popover reveal / copy.
371    #[serde(default)]
372    pub popover_reveal_path: Option<String>,
373}
374
375fn normalized_popover_reveal_path(payload: &TrayNowPlayingPayload) -> Option<String> {
376    payload
377        .popover_reveal_path
378        .as_ref()
379        .map(|s| s.trim().to_string())
380        .filter(|s| !s.is_empty())
381}
382
383fn tray_emit_ui_theme(payload: &TrayNowPlayingPayload) -> String {
384    match payload.ui_theme.as_deref() {
385        Some("light") => "light".to_string(),
386        Some(_) => "dark".to_string(),
387        None => tray_popover_ui_theme_from_prefs(),
388    }
389}
390
391fn tray_playback_speed_merge(payload: &TrayNowPlayingPayload, last: Option<&TrayPopoverEmit>) -> f64 {
392    let fallback = || last.map(|e| e.playback_speed).unwrap_or(1.0);
393    match payload.playback_speed {
394        Some(s) if s.is_finite() => s.clamp(0.25, 2.0),
395        _ => fallback(),
396    }
397}
398
399fn tray_volume_pct_merge(payload: &TrayNowPlayingPayload, last: Option<&TrayPopoverEmit>) -> u8 {
400    let fallback = || last.map(|e| e.volume_pct).unwrap_or(100);
401    match payload.volume_pct {
402        Some(v) if v.is_finite() => v.clamp(0.0, 100.0).round() as u8,
403        _ => fallback(),
404    }
405}
406
407#[tauri::command]
408pub fn tray_popover_action(app: AppHandle<Wry>, action: String) -> Result<(), String> {
409    /* For `volume:<N>` and `speed:<N>` actions, update `TrayState.last_popover_emit` synchronously
410     * with the incoming value. The main window's JS debounces `syncTrayNowPlayingFromPlayback` by
411     * 150 ms, but the `start_tray_host_poll` thread re-emits `tray-popover-state` every 500 ms
412     * using the cached `volume_pct` / `playback_speed`. Without this pre-update, a host poll tick
413     * that fires between the last drag input and the debounced JS sync would broadcast the stale
414     * cached value — once the popover's `_trayVolUserActive` 400 ms guard expires, the slider
415     * snaps back to the old value. Updating the cache here makes the host poll always emit the
416     * latest user intent. */
417    if let Some(rest) = action.strip_prefix("volume:") {
418        if let Ok(n) = rest.parse::<f64>() {
419            if let Some(tray_state) = app.try_state::<TrayState>() {
420                if let Ok(mut guard) = tray_state.inner.lock() {
421                    if let Some(emit) = guard.last_popover_emit.as_mut() {
422                        emit.volume_pct = n.clamp(0.0, 100.0).round() as u8;
423                    }
424                }
425            }
426        }
427    } else if let Some(rest) = action.strip_prefix("speed:") {
428        if let Ok(s) = rest.parse::<f64>() {
429            if s.is_finite() {
430                if let Some(tray_state) = app.try_state::<TrayState>() {
431                    if let Ok(mut guard) = tray_state.inner.lock() {
432                        if let Some(emit) = guard.last_popover_emit.as_mut() {
433                            emit.playback_speed = s.clamp(0.25, 2.0);
434                        }
435                    }
436                }
437            }
438        }
439    } else if let Some(rest) = action.strip_prefix("seek:") {
440        /* Seek directly to the audio-engine from Rust rather than round-tripping through the
441         * main window's `listen('menu-action')` → `seekPlaybackToPercent` path. The main webview
442         * can be suspended by WebKit when it's in another Space, minimized, or occluded, which
443         * means the JS listener doesn't run until the user clicks the main app to bring it
444         * forward — the user observed: "moving the playback slider in tray popover doesn't move
445         * the playhead until you click on main app". Firing `playback_seek` here is
446         * webview-state-independent. We still emit `menu-action` below so the main window's
447         * waveform / now-playing UI picks up the new position on the next poll tick OR as soon
448         * as it resumes. */
449        if let Ok(frac) = rest.parse::<f64>() {
450            if frac.is_finite() {
451                let frac = frac.clamp(0.0, 1.0);
452                let total_sec = app
453                    .try_state::<TrayState>()
454                    .and_then(|s| s.inner.lock().ok().and_then(|g| {
455                        g.last_popover_emit.as_ref().and_then(|e| e.total_sec)
456                    }));
457                if let Some(dur) = total_sec {
458                    if dur > 0.0 {
459                        let position_sec = frac * dur;
460                        std::thread::spawn(move || {
461                            let _ = crate::audio_engine::spawn_audio_engine_request(
462                                &serde_json::json!({
463                                    "cmd": "playback_seek",
464                                    "position_sec": position_sec,
465                                }),
466                            );
467                        });
468                    }
469                }
470            }
471        }
472    }
473    // Same delivery path as `on_menu_event` in lib.rs: only the **main** webview runs `ipc.js`
474    // playback handlers — broadcast `emit` does not reliably hit the main window listener.
475    let _ = app.emit_to("main", "menu-action", action);
476    Ok(())
477}
478
479/// Fit the `tray-popover` WebView to content (title lines, meta, fonts). Called from `tray-popover.js`.
480/// Width/height are **CSS / logical** pixels (same units as `getBoundingClientRect`); `PhysicalSize` would
481/// undersize on HiDPI and clip the HUD.
482#[tauri::command]
483pub fn tray_popover_resize(app: AppHandle<Wry>, width: f64, height: f64) -> Result<(), String> {
484    let Some(win) = app.get_webview_window("tray-popover") else {
485        return Ok(());
486    };
487    let w = width.clamp(240.0, 620.0);
488    /* Tall cap: meta + wrapped directory path; `tray-popover.js` measures `#shell` scroll height.
489     * Low floor (60 px) so a near-empty idle popover can actually shrink — a higher minimum
490     * leaves transparent padding at the bottom that swallows clicks outside the visible shell. */
491    let h = height.clamp(60.0, 1200.0);
492    let _ = win.set_size(tauri::Size::Logical(LogicalSize::new(w, h)));
493    Ok(())
494}
495
496/// **Tauri v2 IPC:** call `invoke('update_tray_now_playing', { payload: … })` — the outer key must be
497/// `payload` (matches this parameter name); a flat object fails deserialization.
498#[tauri::command]
499pub fn update_tray_now_playing(
500    app: AppHandle<Wry>,
501    tray_state: State<'_, TrayState>,
502    payload: TrayNowPlayingPayload,
503) -> Result<(), String> {
504    let mut guard = tray_state
505        .inner
506        .lock()
507        .map_err(|_| "tray state mutex poisoned".to_string())?;
508    let Some(tray) = guard.tray.clone() else {
509        return Ok(());
510    };
511
512    let np_line = if payload.idle {
513        None
514    } else {
515        payload
516            .title_bar
517            .as_ref()
518            .map(|s| s.trim())
519            .filter(|s| !s.is_empty())
520            .map(|s| s.to_string())
521    };
522    guard.now_playing_menu_line.clone_from(&np_line);
523
524    if let Some(ref map) = payload.appearance {
525        if !map.is_empty() {
526            guard.last_tray_appearance = Some(map.clone());
527        }
528    }
529
530    let theme = tray_emit_ui_theme(&payload);
531    let appearance = guard.last_tray_appearance.clone();
532    let last_emit = guard.last_popover_emit.as_ref();
533    let prev_reveal_path = last_emit.and_then(|e| e.reveal_path.clone());
534    let playback_speed = tray_playback_speed_merge(&payload, last_emit);
535    let volume_pct = tray_volume_pct_merge(&payload, last_emit);
536    let emit = if payload.idle {
537        TrayPopoverEmit {
538            idle: true,
539            title: String::new(),
540            subtitle: String::new(),
541            reveal_path: None,
542            elapsed_sec: 0.0,
543            total_sec: None,
544            playing: false,
545            playback_speed,
546            volume_pct,
547            idle_hint: payload
548                .popover_idle_label
549                .clone()
550                .filter(|s| !s.trim().is_empty()),
551            ui_theme: theme,
552            appearance: appearance.clone(),
553        }
554    } else {
555        TrayPopoverEmit {
556            idle: false,
557            title: payload.popover_title.clone().unwrap_or_default(),
558            subtitle: payload.popover_subtitle.clone().unwrap_or_default(),
559            reveal_path: normalized_popover_reveal_path(&payload),
560            elapsed_sec: payload.elapsed_sec.unwrap_or(0.0),
561            total_sec: payload.total_sec,
562            playing: payload.popover_playing.unwrap_or(false),
563            playback_speed,
564            volume_pct,
565            idle_hint: None,
566            ui_theme: theme,
567            appearance: appearance.clone(),
568        }
569    };
570    guard.last_popover_emit = Some(emit.clone());
571
572    let menu = build_tray_popup_menu(
573        &app,
574        &guard.menu_strings,
575        guard.now_playing_menu_line.as_deref(),
576    )?;
577    drop(guard);
578    let _ = tray.set_menu(Some(menu));
579    let _ = tray.set_tooltip(Some(payload.tooltip.as_str()));
580    /* Menu-bar status item shows icon only — the track name still appears as the first row of the
581     * dropdown menu + in the popover HUD + the hover tooltip, but nothing is drawn next to the icon. */
582    #[cfg(target_os = "macos")]
583    {
584        let _ = tray.set_title(None::<&str>);
585    }
586    emit_tray_popover_state(&app, &emit);
587
588    /* SMB directory metadata pre-warmer: when the now-playing `reveal_path` changes, walk the
589     * parent directory on a detached thread so Finder's eventual "Reveal in Finder" click lands
590     * on a warm SMB stat cache instead of paying a multi-second network round-trip for the
591     * listing. For local SSD libraries this is microseconds and a no-op.
592     *
593     * We deliberately do NOT pre-read the audio file content here anymore — the audio-engine
594     * now slurps the full file into a `MemoryBlock` at playback start (see
595     * `Engine.cpp::playbackLoad`), so the file is pinned in process-heap RAM for the lifetime
596     * of the track and is immune to `smbfs` UBC eviction under Finder's memory pressure. A Rust
597     * parallel read-through would just double the initial SMB bandwidth used at track change
598     * with zero benefit. */
599    let new_reveal_path = emit.reveal_path.clone();
600    if new_reveal_path != prev_reveal_path {
601        if let Some(rp) = new_reveal_path {
602            std::thread::spawn(move || {
603                let p = std::path::Path::new(&rp);
604                if let Some(parent) = p.parent() {
605                    if !parent.as_os_str().is_empty() {
606                        if let Ok(entries) = std::fs::read_dir(parent) {
607                            for entry in entries.flatten() {
608                                let _ = entry.metadata();
609                            }
610                        }
611                    }
612                }
613            });
614        }
615    }
616    Ok(())
617}
618
619#[tauri::command]
620pub fn tray_popover_get_state(tray_state: State<'_, TrayState>) -> Result<Option<TrayPopoverEmit>, String> {
621    let guard = tray_state
622        .inner
623        .lock()
624        .map_err(|_| "tray state mutex poisoned".to_string())?;
625    Ok(guard.last_popover_emit.clone())
626}
627
628#[tauri::command]
629pub fn tray_popover_get_ui_theme() -> String {
630    tray_popover_ui_theme_from_prefs()
631}
632
633/// Bring the main window forward (tray popover context menu — same intent as the native tray “Show” item).
634#[tauri::command]
635pub fn show_main_window(app: AppHandle<Wry>) -> Result<(), String> {
636    let Some(w) = app.get_webview_window("main") else {
637        return Ok(());
638    };
639    w.show().map_err(|e| e.to_string())?;
640    w.unminimize().map_err(|e| e.to_string())?;
641    w.set_focus().map_err(|e| e.to_string())?;
642    Ok(())
643}
644
645/// Hide the tray popover. Invoked from the main window (Escape keybind in `ipc.js`) so the user
646/// can dismiss the popover from any focused window — `tray-popover.js`'s own `document.keydown`
647/// Escape listener only fires when the popover webview itself has keyboard focus, which doesn't
648/// happen if the popover was shown with `focus: false` and the user never clicked into it.
649#[tauri::command]
650pub fn tray_popover_hide(app: AppHandle<Wry>) -> Result<(), String> {
651    if let Some(win) = app.get_webview_window("tray-popover") {
652        let _ = win.hide();
653    }
654    Ok(())
655}
656
657static TRAY_POLL_ACTIVE: AtomicBool = AtomicBool::new(false);
658/// Host-side poll interval — 500 ms matches the popover UI's expected cadence and is cheap since the
659/// audio-engine stdin/stdout JSON request is just a few bytes.
660const TRAY_POLL_MS: u64 = 500;
661
662fn fmt_tray_time(sec: f64) -> String {
663    let s = sec.max(0.0);
664    let m = (s / 60.0) as u64;
665    let r = (s as u64) % 60;
666    format!("{}:{:02}", m, r)
667}
668
669fn truncate_tray_title(s: &str) -> String {
670    const MAX: usize = 44;
671    let t = s.trim();
672    if t.chars().count() <= MAX {
673        return t.to_string();
674    }
675    let mut out: String = t.chars().take(MAX.saturating_sub(1)).collect();
676    out.push('…');
677    out
678}
679
680/// Background thread that polls `audio-engine` `playback_status` and pushes fresh elapsed / total /
681/// paused state to the **tray icon** and the **tray-popover** WebView, regardless of JS timer
682/// throttling. The JS side (`update_tray_now_playing` in `audio.js`) still owns the **title** and
683/// **subtitle** — those come from DOM state that Rust cannot see — but this thread keeps the
684/// **elapsed / total / playing** fields live when the main window is unfocused (on macOS the rAF
685/// loop and `setInterval` both pause behind `isUiIdleHeavyCpu`, leaving the tray frozen).
686///
687/// The thread is **idempotent** (guarded by `TRAY_POLL_ACTIVE`) and runs for the lifetime of the
688/// app. On each tick:
689///  1. Poll `playback_status` from audio-engine.
690///  2. If `loaded != true`, skip (HTML5 / reverse playback does not reach the engine poll; the JS
691///     `timeupdate` + keepalive paths handle those).
692///  3. Merge fresh position / duration / paused into the last JS-reported `TrayPopoverEmit`.
693///  4. Update `tray.set_title` (macOS) + `tray.set_tooltip` and emit `tray-popover-state`.
694pub fn start_tray_host_poll(app: AppHandle<Wry>) {
695    if TRAY_POLL_ACTIVE.swap(true, Ordering::SeqCst) {
696        return;
697    }
698    thread::spawn(move || {
699        while TRAY_POLL_ACTIVE.load(Ordering::SeqCst) {
700            thread::sleep(Duration::from_millis(TRAY_POLL_MS));
701            if !TRAY_POLL_ACTIVE.load(Ordering::SeqCst) {
702                break;
703            }
704            /* Short-circuit BEFORE touching the audio-engine — no point spawning the child process
705             * or locking its stdin mutex until JS has reported a non-idle track. */
706            let Some(tray_state) = app.try_state::<TrayState>() else {
707                continue;
708            };
709            {
710                let guard = match tray_state.inner.lock() {
711                    Ok(g) => g,
712                    Err(_) => continue,
713                };
714                match guard.last_popover_emit.as_ref() {
715                    Some(e) if !e.idle => {}
716                    _ => continue,
717                }
718            }
719            let v = match crate::audio_engine::spawn_audio_engine_request(
720                &serde_json::json!({ "cmd": "playback_status" }),
721            ) {
722                Ok(v) => v,
723                Err(_) => continue,
724            };
725            let loaded = v.get("loaded").and_then(|x| x.as_bool()).unwrap_or(false);
726            if !loaded {
727                continue;
728            }
729            let pos = v
730                .get("position_sec")
731                .and_then(|x| x.as_f64())
732                .unwrap_or(0.0);
733            let dur = v
734                .get("duration_sec")
735                .and_then(|x| x.as_f64())
736                .unwrap_or(0.0);
737            let paused = v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false);
738            let (tray, new_emit, title_bar, tooltip) = {
739                let mut guard = match tray_state.inner.lock() {
740                    Ok(g) => g,
741                    Err(_) => continue,
742                };
743                let Some(tray) = guard.tray.clone() else {
744                    continue;
745                };
746                let Some(last) = guard.last_popover_emit.clone() else {
747                    continue;
748                };
749                /* Do not overwrite an explicit idle state — JS has torn down playback; the thread
750                 * must not resurrect a fake "still playing" state from a stale position read. */
751                if last.idle {
752                    continue;
753                }
754                /* If the engine reports a fresh duration, prefer it; otherwise hold the last value so
755                 * the popover does not flash "—" mid-track. */
756                let total_sec = if dur > 0.0 { Some(dur) } else { last.total_sec };
757                let new_emit = TrayPopoverEmit {
758                    idle: false,
759                    title: last.title.clone(),
760                    subtitle: last.subtitle.clone(),
761                    reveal_path: last.reveal_path.clone(),
762                    elapsed_sec: pos,
763                    total_sec,
764                    playing: !paused,
765                    playback_speed: last.playback_speed,
766                    volume_pct: last.volume_pct,
767                    idle_hint: None,
768                    ui_theme: last.ui_theme.clone(),
769                    appearance: last.appearance.clone(),
770                };
771                guard.last_popover_emit = Some(new_emit.clone());
772
773                let total_str = match total_sec {
774                    Some(t) if t > 0.0 => fmt_tray_time(t),
775                    _ => "—".to_string(),
776                };
777                let elapsed_str = fmt_tray_time(pos);
778                /* Menu-bar title is track name only — elapsed/total stay in the popover + tooltip. */
779                let title_bar = truncate_tray_title(&new_emit.title);
780                let status = if new_emit.playing {
781                    "Playing"
782                } else {
783                    "Paused"
784                };
785                let tooltip = if new_emit.title.is_empty() {
786                    format!("{} / {} • {}", elapsed_str, total_str, status)
787                } else {
788                    format!(
789                        "{} — {} / {} • {}",
790                        new_emit.title, elapsed_str, total_str, status
791                    )
792                };
793                (tray, new_emit, title_bar, tooltip)
794            };
795
796            /* Status-item icon only — title stays unset (see `update_tray_now_playing`). */
797            let _ = title_bar;
798            let _ = tray.set_tooltip(Some(tooltip.as_str()));
799            emit_tray_popover_state(&app, &new_emit);
800        }
801        TRAY_POLL_ACTIVE.store(false, Ordering::SeqCst);
802    });
803}