mnml-rs 0.2.20

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
//! Now-playing chip poller — drains the background `now_playing`
//! source (mixr quick.txt + macOS Music/Spotify via osascript) and
//! surfaces the result for the statusline `♪` chip.
//!
//! Extracted from `app/mod.rs` in the file-split refactor
//!. Pure non-destructive move.

use super::*;

impl App {
    /// Per-event-loop housekeeping (cheap).
    /// Start the background now-playing poller — call once, from the
    /// real terminal loop only (`tui.rs`). Headless / e2e deliberately
    /// skip it so no `osascript` subprocess spawns in tests; the
    /// miniplayer chip just renders its idle form there.
    pub fn start_now_playing_poller(&mut self) {
        if self.now_playing_rx.is_none() {
            let source = match self.config.ui.now_playing_source.as_str() {
                "mixr" => crate::now_playing::Source::Mixr,
                "macos" => crate::now_playing::Source::Macos,
                _ => crate::now_playing::Source::Auto,
            };
            self.now_playing_rx = Some(crate::now_playing::spawn_poller(source));
        }
    }

    /// Drain the now-playing poller channel into `now_playing` — the
    /// latest snapshot wins, with a small stickiness layer that
    /// keeps the previous mixr track on screen across the brief
    /// "between songs" / `playing_active` flag dips mixr writes
    /// every few cycles. Without this, the statusline `♪` chip
    /// flickers back to the idle `♪ mixr` form for a poll cycle or
    /// two even while the deck is producing audio (user-reported
    /// 2026-06-17). After ~10 s of confirmed empty mixr reads, the
    /// sticky cache lapses — that's a true queue-empty / deck-cleared
    /// state and the chip should reflect it.
    pub(super) fn drain_now_playing(&mut self) {
        if self.now_playing_rx.is_none() {
            return;
        }
        // Take + replace pattern so the immutable borrow on the
        // receiver doesn't conflict with `&mut self` in
        // `merge_now_playing`.
        let mut drained: Vec<Option<crate::now_playing::NowPlaying>> = Vec::new();
        if let Some(rx) = &self.now_playing_rx {
            while let Ok(np) = rx.try_recv() {
                drained.push(np);
            }
        }
        for np in drained {
            let merged = self.merge_now_playing(np);
            self.now_playing = merged;
        }
    }

    /// Stickiness rule for `drain_now_playing` — pulled out so the
    /// merge logic is unit-testable without spinning up the channel.
    fn merge_now_playing(
        &mut self,
        new: Option<crate::now_playing::NowPlaying>,
    ) -> Option<crate::now_playing::NowPlaying> {
        let now = std::time::Instant::now();
        const STICKY_TTL: std::time::Duration = std::time::Duration::from_secs(10);
        let (new_is_mixr, new_track_empty) = new
            .as_ref()
            .map(|n| (n.source.eq_ignore_ascii_case("mixr"), n.track.is_empty()))
            .unwrap_or((false, true));
        if new_is_mixr && !new_track_empty {
            // Real track read — refresh the sticky timestamp.
            self.last_mixr_track_at = Some(now);
            return new;
        }
        // Empty mixr read (or no mixr at all): if we recently had a
        // mixr track and TTL hasn't lapsed, paper over the gap by
        // keeping the previous snapshot's track. Don't extend the
        // timestamp — only a fresh non-empty read does that, so a
        // genuine queue-empty state still lapses after STICKY_TTL.
        if new_is_mixr
            && let Some(ts) = self.last_mixr_track_at
            && now.duration_since(ts) <= STICKY_TTL
            && let Some(old) = self.now_playing.as_ref()
            && !old.track.is_empty()
        {
            let mut merged = new.clone().unwrap_or_else(|| old.clone());
            merged.track = old.track.clone();
            if merged.detail.is_empty() {
                merged.detail = old.detail.clone();
            }
            return Some(merged);
        }
        new
    }

    /// #1151 (2026-08-23) — marquee/autoscroll for the now-
    /// playing track label. When `[ui] now_playing_marquee`
    /// is on and the current label overflows the 28-char
    /// display window, advance the offset by 1 char every
    /// 300ms (~3.3 chars/sec — comfortable read speed). The
    /// statusline chip does the actual slice; this method
    /// just paces the offset. No-op when the knob is off,
    /// nothing is playing, or the label fits.
    pub(super) fn tick_now_playing_marquee(&mut self) {
        if !self.config.ui.now_playing_marquee {
            self.now_playing_marquee_offset = 0;
            return;
        }
        let Some(np) = self.now_playing.as_ref() else {
            self.now_playing_marquee_offset = 0;
            return;
        };
        if np.track.is_empty() {
            self.now_playing_marquee_offset = 0;
            return;
        }
        // Cheap change-detection first: source + track + detail
        // is a superset of what composes `clean`, so a hash-equal
        // triple can't yield a different composed label. Only
        // rebuild the composed string when one of the inputs
        // actually differs — reviewer catch on 894bd231 (avoids
        // per-tick alloc on the hot path when the track hasn't
        // changed).
        let np_key = format!("{}\x1f{}\x1f{}", np.source, np.detail, np.track);
        if np_key != self.now_playing_marquee_prev_text {
            self.now_playing_marquee_prev_text = np_key;
            self.now_playing_marquee_offset = 0;
            self.now_playing_marquee_last_tick = None;
            return;
        }
        // Same track as last tick — pace the offset off the
        // 300ms wall-clock, no string work.
        let now = std::time::Instant::now();
        let due = match self.now_playing_marquee_last_tick {
            Some(t) => now.duration_since(t) >= std::time::Duration::from_millis(300),
            None => true,
        };
        if !due {
            return;
        }
        // Due — now we DO need `clean` for the loop-len math.
        // Rebuild + skip when the label fits in the 28-char
        // window (nothing to scroll).
        let mixr_is_source = np.source.eq_ignore_ascii_case("mixr");
        let raw = if mixr_is_source || np.detail.is_empty() {
            np.track.clone()
        } else {
            format!("{} - {}", np.detail, np.track)
        };
        let clean = raw.split_whitespace().collect::<Vec<_>>().join(" ");
        if clean.chars().count() <= 28 {
            self.now_playing_marquee_offset = 0;
            return;
        }
        // Loop length = text_chars + 3-char gap so the wrap
        // shows a visual break instead of the tail-of-line
        // colliding with head-of-line.
        let loop_len = clean.chars().count() + 3;
        self.now_playing_marquee_offset = (self.now_playing_marquee_offset + 1) % loop_len;
        self.now_playing_marquee_last_tick = Some(now);
    }
}